🎓 30 - 毕业综合项目

阶段五:实战项目 · 第30课
✅ 验证通过

📖 核心概念

恭喜你走到最后一课!🎓 毕业综合项目将整合前29课的所有核心知识——状态管理、网络请求、本地存储、动画、主题、国际化、平台适配——构建一个旅行日记App。这不是简单的Demo,而是一个功能完整、设计精良、可发布的应用。

🗺️ 旅行日记App - 项目规划

1. 功能需求与架构设计

// 功能模块
// ┌─────────────────────────────────────────────┐
// │  旅行日记 App                                │
// ├──────────┬──────────┬──────────┬──────────────┤
// │  首页     │  探索     │  我的     │  设置        │
// │  Feed流   │  地图     │  个人信息  │  主题切换     │
// │  推荐     │  热门地点  │  我的日记  │  语言切换     │
// │  搜索     │  附近     │  收藏     │  数据管理     │
// ├──────────┴──────────┴──────────┴──────────────┤
// │  核心能力层                                    │
// │  State(Provider) | Network(Dio) | DB(Sqflite) │
// │  Auth(Firebase) | Maps(Google) | i18n(intl)   │
// └──────────────────────────────────────────────┘

// 项目结构
lib/
├── main.dart              # 入口 + 多Provider包裹
├── l10n/                  # 国际化
│   ├── app_zh.arb
│   └── app_en.arb
├── models/                # 数据模型
│   ├── trip.dart
│   ├── diary_entry.dart
│   ├── location.dart
│   └── user_profile.dart
├── providers/             # 状态管理
│   ├── auth_provider.dart
│   ├── trip_provider.dart
│   ├── map_provider.dart
│   └── settings_provider.dart
├── services/              # 服务层
│   ├── api_service.dart
│   ├── database_service.dart
│   ├── location_service.dart
│   ├── photo_service.dart
│   └── notification_service.dart
├── pages/                 # 页面
│   ├── home_page.dart
│   ├── explore_page.dart
│   ├── profile_page.dart
│   ├── settings_page.dart
│   ├── trip_detail_page.dart
│   ├── create_diary_page.dart
│   └── map_page.dart
├── widgets/               # 通用组件
│   ├── trip_card.dart
│   ├── diary_card.dart
│   ├── photo_grid.dart
│   ├── rating_stars.dart
│   └── adaptive_scaffold.dart
└── utils/                 # 工具类
    ├── breakpoints.dart
    ├── formatters.dart
    └── constants.dart

📝 核心模型

2. 旅行与日记数据模型

// lib/models/trip.dart
class Trip {
  final String id;
  final String title;
  final String? coverUrl;
  final String destination;
  final LatLng location;
  final DateTime startDate;
  final DateTime endDate;
  final String description;
  final List<DiaryEntry> entries;
  final double rating;
  final List<String> tags;
  final String userId;
  final int likes;
  final bool isPublic;
  final DateTime createdAt;

  const Trip({
    required this.id, required this.title, this.coverUrl,
    required this.destination, required this.location,
    required this.startDate, required this.endDate,
    required this.description, this.entries = const [],
    this.rating = 0, this.tags = const [], required this.userId,
    this.likes = 0, this.isPublic = true, required this.createdAt,
  });

  int get durationDays => endDate.difference(startDate).inDays + 1;
  bool get isOngoing => DateTime.now().isAfter(startDate)
    && DateTime.now().isBefore(endDate);
  bool get isPast => DateTime.now().isAfter(endDate);
  bool get isFuture => DateTime.now().isBefore(startDate);
}

// lib/models/diary_entry.dart
class DiaryEntry {
  final String id;
  final String tripId;
  final String title;
  final String content;
  final List<String> imageUrls;
  final LatLng? location;
  final String? locationName;
  final double? rating;         // 当日评分
  final String mood;            // 心情emoji
  final String weather;         // 天气描述
  final DateTime date;
  final DateTime createdAt;

  const DiaryEntry({
    required this.id, required this.tripId, required this.title,
    required this.content, this.imageUrls = const [],
    this.location, this.locationName, this.rating,
    this.mood = '😊', this.weather = '☀️',
    required this.date, required this.createdAt,
  });
}

🏠 首页Feed流

3. 整合多个Provider的首页

// lib/pages/home_page.dart
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(
        title: Text(l10n.appTitle),
        actions: [
          // 搜索
          IconButton(
            icon: const Icon(Icons.search),
            onPressed: () => showSearch(
              context: context,
              delegate: TripSearchDelegate(),
            ),
          ),
          // 通知
          NotificationBadge(),
        ],
      ),
      body: Consumer<TripProvider>(
        builder: (context, provider, _) {
          if (provider.isLoading && provider.trips.isEmpty) {
            return const Center(child: CircularProgressIndicator());
          }

          return RefreshIndicator(
            onRefresh: () => provider.refresh(),
            child: CustomScrollView(
              slivers: [
                // 精选旅行(横向滚动)
                if (provider.featuredTrips.isNotEmpty)
                  SliverToBoxAdapter(
                    child: _FeaturedSection(trips: provider.featuredTrips),
                  ),

                // 推荐旅行(纵向列表)
                SliverPadding(
                  padding: ResponsiveHelper.bodyPadding(context),
                  sliver: SliverGrid(
                    gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                      crossAxisCount: ResponsiveHelper.gridColumns(context),
                      childAspectRatio: 0.72,
                      crossAxisSpacing: 12,
                      mainAxisSpacing: 12,
                    ),
                    delegate: SliverChildBuilderDelegate(
                      (context, index) {
                        if (index == provider.trips.length) {
                          return const Center(child: CircularProgressIndicator());
                        }
                        return TripCard(
                          trip: provider.trips[index],
                          onTap: () => _navigateToDetail(context, provider.trips[index]),
                        );
                      },
                      childCount: provider.trips.length + (provider.hasMore ? 1 : 0),
                    ),
                  ),
                ),
              ],
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: () => _navigateToCreate(context),
        icon: const Icon(Icons.add),
        label: Text(l10n.createNewTrip),
      ),
    );
  }
}

🗺️ 地图与定位

4. 旅行地图页面

// lib/pages/map_page.dart
class MapPage extends StatefulWidget {
  final List<Trip> trips;

  @override
  State<MapPage> createState() => _MapPageState();
}

class _MapPageState extends State<MapPage> {
  GoogleMapController? _mapController;
  Set<Marker> _markers = {};

  @override
  void initState() {
    super.initState();
    _buildMarkers();
  }

  void _buildMarkers() {
    _markers = widget.trips.map((trip) => Marker(
      markerId: MarkerId(trip.id),
      position: LatLng(trip.location.latitude, trip.location.longitude),
      infoWindow: InfoWindow(
        title: trip.title,
        snippet: '${trip.destination} · ${trip.startDate.year}',
        onTap: () => _navigateToDetail(trip),
      ),
      icon: BitmapDescriptor.defaultMarkerWithHue(
        _getMarkerHue(trip),
      ),
    )).toSet();
  }

  double _getMarkerHue(Trip trip) {
    if (trip.isOngoing) return BitmapDescriptor.hueGreen;
    if (trip.isFuture) return BitmapDescriptor.hueAzure;
    return BitmapDescriptor.hueViolet;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('旅行地图')),
      body: GoogleMap(
        initialCameraPosition: const CameraPosition(
          target: LatLng(35.86, 104.19), // 中国中心
          zoom: 4,
        ),
        markers: _markers,
        onMapCreated: (controller) => _mapController = controller,
        onCameraMove: _onCameraMove,
        myLocationEnabled: true,
        myLocationButtonEnabled: true,
      ),
    );
  }
}

📸 日记创建页面

5. 富文本+图片+定位+评分

// lib/pages/create_diary_page.dart
class CreateDiaryPage extends StatefulWidget {
  final String tripId;

  @override
  State<CreateDiaryPage> createState() => _CreateDiaryPageState();
}

class _CreateDiaryPageState extends State<CreateDiaryPage> {
  final _titleController = TextEditingController();
  final _contentController = TextEditingController();
  final _formKey = GlobalKey<FormState>();
  List<File> _selectedImages = [];
  LatLng? _selectedLocation;
  String _selectedMood = '😊';
  double _rating = 3;
  bool _isSaving = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('写日记'),
        actions: [
          FilledButton(
            onPressed: _isSaving ? null : _save,
            child: _isSaving
              ? const SizedBox(width: 20, height: 20,
                  child: CircularProgressIndicator(strokeWidth: 2))
              : const Text('发布'),
          ),
          const SizedBox(width: 8),
        ],
      ),
      body: Form(
        key: _formKey,
        child: ListView(
          padding: const EdgeInsets.all(16),
          children: [
            // 日期选择
            ListTile(
              leading: const Icon(Icons.calendar_today),
              title: Text(DateFormat('yyyy年MM月dd日').format(DateTime.now())),
              trailing: const Icon(Icons.chevron_right),
              onTap: _selectDate,
            ),
            const Divider(),

            // 标题
            TextFormField(
              controller: _titleController,
              decoration: const InputDecoration(
                hintText: '日记标题',
                border: InputBorder.none,
                contentPadding: EdgeInsets.zero,
              ),
              style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
              validator: (v) => v?.trim().isEmpty == true ? '请输入标题' : null,
            ),
            const SizedBox(height: 12),

            // 心情 + 天气
            Row(
              children: [
                _MoodSelector(
                  selected: _selectedMood,
                  onSelected: (m) => setState(() => _selectedMood = m),
                ),
                const Spacer(),
                _RatingStars(
                  rating: _rating,
                  onRatingChanged: (r) => setState(() => _rating = r),
                ),
              ],
            ),
            const SizedBox(height: 12),

            // 内容
            TextFormField(
              controller: _contentController,
              decoration: const InputDecoration(
                hintText: '记录今天的旅程...',
                border: InputBorder.none,
              ),
              maxLines: null,
              minLines: 5,
              validator: (v) => v?.trim().isEmpty == true ? '请输入内容' : null,
            ),
            const SizedBox(height: 16),

            // 图片选择
            _ImagePicker(
              images: _selectedImages,
              onAdd: _pickImages,
              onRemove: (i) => setState(() => _selectedImages.removeAt(i)),
            ),
            const SizedBox(height: 16),

            // 位置标记
            ListTile(
              leading: const Icon(Icons.location_on),
              title: Text(_selectedLocation != null ? '已标记位置' : '标记位置'),
              trailing: const Icon(Icons.chevron_right),
              onTap: _pickLocation,
              shape: RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(12),
                side: BorderSide(color: Theme.of(context).colorScheme.outline),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Future<void> _save() async {
    if (!_formKey.currentState!.validate()) return;

    setState(() => _isSaving = true);
    try {
      // 上传图片
      final imageUrls = await Future.wait(
        _selectedImages.map((f) => PhotoService().upload(f)),
      );

      // 创建日记
      final entry = DiaryEntry(
        id: const Uuid().v4(),
        tripId: widget.tripId,
        title: _titleController.text.trim(),
        content: _contentController.text.trim(),
        imageUrls: imageUrls,
        location: _selectedLocation,
        rating: _rating,
        mood: _selectedMood,
        date: DateTime.now(),
        createdAt: DateTime.now(),
      );

      await context.read<TripProvider>().addDiaryEntry(entry);
      if (mounted) Navigator.pop(context, true);
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('保存失败:$e')),
      );
    } finally {
      if (mounted) setState(() => _isSaving = false);
    }
  }
}

🧩 应用入口与Provider装配

6. main.dart - 万物归一

// lib/main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // 初始化服务
  await dotenv.load(fileName: '.env');
  final db = await DatabaseService().initialize();
  final prefs = await SharedPreferences.getInstance();

  runApp(TravelDiaryApp(db: db, prefs: prefs));
}

class TravelDiaryApp extends StatelessWidget {
  final Database db;
  final SharedPreferences prefs;

  const TravelDiaryApp({required this.db, required this.prefs});

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => ThemeManager(prefs)),
        ChangeNotifierProvider(create: (_) => LocaleProvider(prefs)),
        ChangeNotifierProvider(create: (_) => AuthProvider()),
        ChangeNotifierProvider(create: (_) => TripProvider(db)),
        ChangeNotifierProvider(create: (_) => MapProvider()),
        ChangeNotifierProvider(create: (_) => NotificationProvider()),
      ],
      child: Consumer2<ThemeManager, LocaleProvider>(
        builder: (context, themeMgr, localeMgr, _) {
          return MaterialApp(
            // 国际化
            localizationsDelegates: AppLocalizations.localizationsDelegates,
            supportedLocales: AppLocalizations.supportedLocales,
            locale: localeMgr.locale,

            // 主题
            theme: themeMgr.lightTheme,
            darkTheme: themeMgr.darkTheme,
            themeMode: themeMgr.mode,

            // 路由
            home: const AuthWrapper(),
            debugShowCheckedModeBanner: false,
          );
        },
      ),
    );
  }
}

// 认证包装器
class AuthWrapper extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<AuthProvider>(
      builder: (context, auth, _) {
        if (auth.isLoading) {
          return const SplashScreen();
        }
        if (auth.isAuthenticated) {
          return const MainShell(); // 登录后主界面
        }
        return const LoginPage(); // 未登录
      },
    );
  }
}

// 主Shell - 自适应导航
class MainShell extends StatelessWidget {
  const MainShell();

  @override
  Widget build(BuildContext context) {
    return AdaptiveScaffold(
      selectedIndex: _currentIndex,
      onDestinationSelected: (i) => _navigate(i),
      destinations: [
        NavigationDestination(icon: Icon(Icons.home), label: '首页'),
        NavigationDestination(icon: Icon(Icons.explore), label: '探索'),
        NavigationDestination(icon: Icon(Icons.map), label: '地图'),
        NavigationDestination(icon: Icon(Icons.person), label: '我的'),
      ],
      body: _pages[_currentIndex],
    );
  }
}
✓ 完整App架构:MultiProvider + 路由 + 认证 ✓ 首页Feed:精选+推荐+搜索+下拉刷新 ✓ 日记创建:富文本+多图+定位+评分+心情 ✓ 地图集成:标记点+定位+信息窗 ✓ 主题切换:亮/暗/自定义主题色 ✓ 国际化:中英文切换 ✓ 响应式:手机/平板/桌面自适应 ✓ 数据持久化:SQLite离线存储

📝 毕业挑战

  1. 为旅行日记App添加社交功能——关注旅行者、点赞日记、评论交流
  2. 实现AI旅行助手——集成LLM API,根据目的地推荐行程
  3. 添加离线地图支持——使用flutter_map+离线瓦片
  4. 实现数据云同步——Firebase Firestore实时同步多设备数据
  5. 发布到Google Play和App Store——运用第25课所学完成上架!

🏆 成就解锁:Flutter大师

🎉 恭喜你完成了全部30课的学习!

你从一个Flutter新手成长为能独立开发完整App的开发者。回顾你的旅程:

你的Flutter之旅才刚刚开始。保持学习,持续实践,构建出改变世界的App!💪