diff --git a/app/lib/core/routing/app_router.dart b/app/lib/core/routing/app_router.dart index 294953b..d7b6433 100644 --- a/app/lib/core/routing/app_router.dart +++ b/app/lib/core/routing/app_router.dart @@ -42,6 +42,7 @@ import '../../features/templates/views/template_gallery_page.dart'; import '../../features/settings/views/settings_page.dart'; import '../../features/auth/bloc/auth_bloc.dart'; import '../../features/search/bloc/search_bloc.dart'; +import '../../features/discover/bloc/discover_bloc.dart'; import '../../data/repositories/journal_repository.dart'; import '../../data/remote/api_client.dart'; @@ -168,7 +169,13 @@ GoRouter createAppRouter(AuthBloc authBloc) { GoRoute( path: '/discover', name: 'discover', - builder: (context, state) => const DiscoverPage(), + builder: (context, state) { + return BlocProvider( + create: (_) => DiscoverBloc(api: context.read()) + ..add(const DiscoverLoadData()), + child: const DiscoverPage(), + ); + }, ), // 个人中心 GoRoute( diff --git a/app/lib/features/class_/views/class_page.dart b/app/lib/features/class_/views/class_page.dart index 9098134..b286994 100644 --- a/app/lib/features/class_/views/class_page.dart +++ b/app/lib/features/class_/views/class_page.dart @@ -313,7 +313,7 @@ class _DiaryWallCard extends StatelessWidget { radius: 16, backgroundColor: AppColors.rose.withValues(alpha: 0.2), child: Text( - '同', + journal.title.isNotEmpty ? journal.title[0] : '日', style: theme.textTheme.labelMedium?.copyWith(color: AppColors.rose), ), ), diff --git a/app/lib/features/home/views/home_page.dart b/app/lib/features/home/views/home_page.dart index 65e049c..8828e19 100644 --- a/app/lib/features/home/views/home_page.dart +++ b/app/lib/features/home/views/home_page.dart @@ -91,7 +91,10 @@ class _HomeView extends StatelessWidget { children: [ _GreetingHeader( greeting: greeting, - username: '小暖', + username: context.select((bloc) { + final s = bloc.state; + return s is Authenticated ? s.user.displayLabel : '同学'; + }), dateText: dateText, onSearchTap: () => context.push('/search'), ), diff --git a/app/lib/features/profile/views/profile_page.dart b/app/lib/features/profile/views/profile_page.dart index 8aaf62d..b5cd80f 100644 --- a/app/lib/features/profile/views/profile_page.dart +++ b/app/lib/features/profile/views/profile_page.dart @@ -10,6 +10,8 @@ import 'package:nuanji_app/features/auth/bloc/auth_bloc.dart'; import 'package:nuanji_app/features/profile/bloc/settings_bloc.dart'; import 'package:nuanji_app/data/models/user.dart'; import 'package:nuanji_app/data/repositories/journal_repository.dart'; +import 'package:nuanji_app/features/achievement/bloc/achievement_bloc.dart'; +import 'package:nuanji_app/data/remote/api_client.dart'; /// 个人中心页面 class ProfilePage extends StatelessWidget { @@ -60,7 +62,10 @@ class ProfilePage extends StatelessWidget { ), ), alignment: Alignment.center, - child: const Text('😊', style: TextStyle(fontSize: 36)), + child: Text( + displayName.isNotEmpty ? displayName[0] : '😊', + style: const TextStyle(fontSize: 36, color: Colors.white), + ), ), const SizedBox(height: 12), // 用户名 @@ -91,7 +96,7 @@ class ProfilePage extends StatelessWidget { _LiveStatsBar(borderSoft: borderSoft, colorScheme: colorScheme), const SizedBox(height: 20), - // ---- 成就徽章 ---- + // ---- 成就徽章(动态加载) ---- Align( alignment: Alignment.centerLeft, child: Text('成就徽章', style: theme.textTheme.titleMedium?.copyWith( @@ -101,21 +106,11 @@ class ProfilePage extends StatelessWidget { const SizedBox(height: 12), SizedBox( height: 100, - child: ListView( - scrollDirection: Axis.horizontal, - children: [ - _BadgeItem(emoji: '📝', name: '初出茅庐', bgColor: accentSoft, locked: false), - const SizedBox(width: 12), - _BadgeItem(emoji: '🔥', name: '七日连续', bgColor: tertiarySoft, locked: false), - const SizedBox(width: 12), - _BadgeItem(emoji: '🎨', name: '装饰达人', bgColor: roseSoft, locked: false), - const SizedBox(width: 12), - _BadgeItem(emoji: '🌟', name: '人气之星', bgColor: secondarySoft, locked: true), - const SizedBox(width: 12), - _BadgeItem(emoji: '🏆', name: '写作高手', bgColor: accentSoft, locked: true), - const SizedBox(width: 12), - _BadgeItem(emoji: '💎', name: '全能王', bgColor: tertiarySoft, locked: true), - ], + child: _AchievementBadges( + accentSoft: accentSoft, + tertiarySoft: tertiarySoft, + roseSoft: roseSoft, + secondarySoft: secondarySoft, ), ), const SizedBox(height: 20), @@ -430,9 +425,79 @@ class _LiveStatsBarState extends State<_LiveStatsBar> { VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft), _StatItem(label: '本月日记', value: '$_monthCount', valueColor: widget.colorScheme.onSurface), VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft), - _StatItem(label: '贴纸数', value: '--', valueColor: widget.colorScheme.onSurface), + _StatItem(label: '贴纸数', value: _totalCount > 0 ? '$_totalCount' : '0', valueColor: widget.colorScheme.onSurface), ], ), ); } } + +/// 成就徽章动态组件 — 从 AchievementBloc 加载真实数据 +class _AchievementBadges extends StatefulWidget { + const _AchievementBadges({ + required this.accentSoft, + required this.tertiarySoft, + required this.roseSoft, + required this.secondarySoft, + }); + + final Color accentSoft; + final Color tertiarySoft; + final Color roseSoft; + final Color secondarySoft; + + @override + State<_AchievementBadges> createState() => _AchievementBadgesState(); +} + +class _AchievementBadgesState extends State<_AchievementBadges> { + late final AchievementBloc _bloc; + List _achievements = []; + + @override + void initState() { + super.initState(); + _bloc = AchievementBloc(api: context.read()); + _bloc.load(); + _bloc.addListener(_onUpdate); + } + + void _onUpdate() { + if (mounted) { + setState(() { + _achievements = _bloc.state.achievements; + }); + } + } + + @override + void dispose() { + _bloc.removeListener(_onUpdate); + _bloc.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_achievements.isEmpty) { + return const Center(child: Text('暂无成就', style: TextStyle(fontSize: 13))); + } + + final bgColors = [widget.accentSoft, widget.tertiarySoft, widget.roseSoft, widget.secondarySoft]; + + return ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _achievements.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final a = _achievements[index]; + return _BadgeItem( + emoji: a.icon ?? '🏆', + name: a.name, + bgColor: bgColors[index % bgColors.length], + locked: !a.isUnlocked, + ); + }, + ); + } +} diff --git a/app/lib/features/search/views/search_page.dart b/app/lib/features/search/views/search_page.dart index d96d59b..fbff4f2 100644 --- a/app/lib/features/search/views/search_page.dart +++ b/app/lib/features/search/views/search_page.dart @@ -17,6 +17,9 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/utils/mood_utils.dart'; import '../../../data/models/journal_entry.dart'; +import '../../../data/remote/api_client.dart'; +import '../../../data/repositories/journal_repository.dart'; +import '../../templates/bloc/template_bloc.dart'; import '../bloc/search_bloc.dart'; /// 搜索页面 — 搜索历史 + 热门搜索 + 结果分类 @@ -31,18 +34,42 @@ class _SearchPageState extends State { final _searchController = TextEditingController(); final _searchFocusNode = FocusNode(); - // 热门搜索占位数据 - final _hotSearches = ['日常', '学校', '旅行', '美食', '读书', '心情', '手账', '贴纸']; + // 热门搜索 — 从用户日记标签动态推导,无数据时使用默认推荐 + List _hotSearches = ['日常', '学校', '旅行', '心情', '读书', '手账']; @override void initState() { super.initState(); + _deriveHotSearches(); // 自动弹出键盘 WidgetsBinding.instance.addPostFrameCallback((_) { _searchFocusNode.requestFocus(); }); } + /// 从日记标签频率推导热门搜索 + Future _deriveHotSearches() async { + try { + final repo = context.read(); + final journals = await repo.getJournals(); + final tagFreq = {}; + for (final j in journals) { + for (final tag in j.tags) { + tagFreq[tag] = (tagFreq[tag] ?? 0) + 1; + } + } + final sorted = tagFreq.keys.toList() + ..sort((a, b) => tagFreq[b]!.compareTo(tagFreq[a]!)); + if (sorted.isNotEmpty && mounted) { + setState(() { + _hotSearches = sorted.take(8).toList(); + }); + } + } catch (_) { + // 保持默认值 + } + } + @override void dispose() { _searchController.dispose(); @@ -490,79 +517,10 @@ class _SearchPageState extends State { ); } - // ===== 6E: 模板结果(占位) ===== + // ===== 6E: 模板结果(动态加载) ===== Widget _buildTemplateResults(ThemeData theme, bool isDark) { - // Phase 1 占位 — 模板功能未实现 - return GridView.builder( - padding: const EdgeInsets.all(16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 0.75, - ), - itemCount: 4, - itemBuilder: (context, index) { - final gradients = [ - const [AppColors.accent, AppColors.tertiary], - const [AppColors.secondary, AppColors.tertiary], - const [AppColors.rose, AppColors.accent], - const [AppColors.tertiary, AppColors.secondary], - ]; - final labels = ['每日心情', '旅行手账', '读书笔记', '日常记录']; - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppRadius.md), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: gradients[index], - ), - ), - child: Stack( - children: [ - // 装饰圆 - Positioned( - right: -10, - bottom: -10, - child: Container( - width: 60, - height: 60, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white.withValues(alpha: 0.12), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text( - labels[index], - style: theme.textTheme.titleSmall?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - '即将上线', - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.white.withValues(alpha: 0.7), - ), - ), - ], - ), - ), - ], - ), - ); - }, - ); + return _TemplateSearchGrid(theme: theme, isDark: isDark); } // ===== 6E: 标签结果 ===== @@ -781,3 +739,124 @@ extension _PadAll on Widget { child: this, ); } + +/// 搜索页模板结果 — 从 TemplateBloc 动态加载 +class _TemplateSearchGrid extends StatefulWidget { + const _TemplateSearchGrid({required this.theme, required this.isDark}); + final ThemeData theme; + final bool isDark; + + @override + State<_TemplateSearchGrid> createState() => _TemplateSearchGridState(); +} + +class _TemplateSearchGridState extends State<_TemplateSearchGrid> { + late final TemplateBloc _bloc; + + @override + void initState() { + super.initState(); + _bloc = TemplateBloc(api: context.read()); + _bloc.load(); + _bloc.addListener(() => setState(() {})); + } + + @override + void dispose() { + _bloc.dispose(); + super.dispose(); + } + + static const _gradients = [ + [AppColors.accent, AppColors.tertiary], + [AppColors.secondary, AppColors.tertiary], + [AppColors.rose, AppColors.accent], + [AppColors.tertiary, AppColors.secondary], + ]; + + @override + Widget build(BuildContext context) { + final templates = _bloc.state.templates; + + if (_bloc.state.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (templates.isEmpty) { + return Center( + child: Text('暂无模板', style: widget.theme.textTheme.bodyMedium?.copyWith( + color: widget.isDark ? AppColors.mutedDark : AppColors.mutedLight, + )), + ); + } + + return GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.75, + ), + itemCount: templates.length, + itemBuilder: (context, index) { + final t = templates[index]; + final colors = _gradients[index % _gradients.length]; + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.md), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: colors, + ), + ), + child: Stack( + children: [ + Positioned( + right: -10, + bottom: -10, + child: Container( + width: 60, + height: 60, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.12), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + t.emoji, + style: const TextStyle(fontSize: 28), + ), + const SizedBox(height: 8), + Text( + t.name, + style: widget.theme.textTheme.titleSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + t.isFree ? '免费模板' : '精品模板', + style: widget.theme.textTheme.bodySmall?.copyWith( + color: Colors.white.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/app/lib/features/stickers/views/sticker_library_page.dart b/app/lib/features/stickers/views/sticker_library_page.dart index cc1db94..f29f812 100644 --- a/app/lib/features/stickers/views/sticker_library_page.dart +++ b/app/lib/features/stickers/views/sticker_library_page.dart @@ -19,10 +19,19 @@ class _StickerLibraryPageState extends State { late final StickerBloc _bloc; final _searchController = TextEditingController(); - /// 设计规格中的 8 个分类 - static const _specCategories = [ - '推荐', '可爱', '植物', '手绘', '校园', '节日', '文字', '和纸胶带', - ]; + /// 默认分类 — 从 API 数据动态补充 + static const _defaultCategories = ['推荐', '可爱', '植物', '手绘', '校园', '节日', '文字', '和纸胶带']; + + List get _categories { + final apiCategories = _bloc.state.packs + .map((p) => p.category) + .whereType() + .toSet() + .toList(); + if (apiCategories.isEmpty) return _defaultCategories; + // 合并:推荐 + API 返回的分类 + return ['推荐', ...apiCategories]; + } @override void initState() { @@ -120,7 +129,7 @@ class _StickerLibraryPageState extends State { child: ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), - children: _specCategories.map((cat) { + children: _categories.map((cat) { final isSelected = cat == state.selectedCategory || (cat == '推荐' && state.selectedCategory == '全部'); return Padding( @@ -148,13 +157,13 @@ class _StickerLibraryPageState extends State { ), const SizedBox(height: 12), - // ---- 精选贴纸包卡片 ---- - if (state.selectedCategory == '全部') + // ---- 精选贴纸包卡片(动态数据) ---- + if (state.selectedCategory == '全部' && state.filteredPacks.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: const _FeaturedPackCard(), + child: _FeaturedPackCard(pack: state.filteredPacks.first), ), - if (state.selectedCategory == '全部') + if (state.selectedCategory == '全部' && state.filteredPacks.isNotEmpty) const SizedBox(height: 16), // ---- 贴纸包网格 ---- @@ -188,9 +197,10 @@ class _StickerLibraryPageState extends State { } } -/// 精选贴纸包卡片 — 渐变背景 + 限时免费标签 +/// 精选贴纸包卡片 — 渐变背景 + 动态数据 class _FeaturedPackCard extends StatelessWidget { - const _FeaturedPackCard(); + const _FeaturedPackCard({required this.pack}); + final StickerPack pack; @override Widget build(BuildContext context) { @@ -198,7 +208,7 @@ class _FeaturedPackCard extends StatelessWidget { return GestureDetector( onTap: () { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('打开精选贴纸包: 治愈小动物')), + SnackBar(content: Text('打开精选贴纸包: ${pack.name}')), ); }, child: Container( @@ -214,7 +224,6 @@ class _FeaturedPackCard extends StatelessWidget { ), child: Row( children: [ - // emoji 图标区域 Container( width: 64, height: 64, @@ -223,30 +232,38 @@ class _FeaturedPackCard extends StatelessWidget { borderRadius: AppRadius.mdBorder, ), alignment: Alignment.center, - child: const Text('🧸', style: TextStyle(fontSize: 36)), + child: Text(pack.displayCover, style: const TextStyle(fontSize: 36)), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('治愈小动物', style: theme.textTheme.titleMedium?.copyWith( + Text(pack.name, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, color: Colors.white, )), const SizedBox(height: 4), - Text('超可爱的手绘小动物贴纸', style: theme.textTheme.bodySmall?.copyWith( - color: Colors.white.withValues(alpha: 0.85), - )), + Text( + pack.description ?? '${pack.stickerCount} 张精选贴纸', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white.withValues(alpha: 0.85), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), const SizedBox(height: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: AppColors.secondary, + color: pack.isFree ? AppColors.secondary : AppColors.rose, borderRadius: AppRadius.pillBorder, ), - child: const Text('限时免费', style: TextStyle( - fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white, - )), + child: Text( + pack.isFree ? '免费' : '精品', + style: const TextStyle( + fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white, + ), + ), ), ], ), diff --git a/app/lib/features/teacher/views/teacher_page.dart b/app/lib/features/teacher/views/teacher_page.dart index 7439af4..b995349 100644 --- a/app/lib/features/teacher/views/teacher_page.dart +++ b/app/lib/features/teacher/views/teacher_page.dart @@ -67,11 +67,7 @@ class _TeacherView extends StatelessWidget { iconColor: AppColors.tertiary, title: '班级码管理', subtitle: '查看和重置班级码', - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('班级码: a1b2c3')), - ); - }, + onTap: () => _showClassCodes(context), ), const SizedBox(height: 24), @@ -159,6 +155,40 @@ class _TeacherView extends StatelessWidget { ); } + void _showClassCodes(BuildContext context) { + final classState = context.read().state; + final classes = classState is ClassListLoaded ? classState.classes : []; + + if (classes.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请先创建班级')), + ); + return; + } + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('班级码管理'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: classes.map((c) => ListTile( + leading: const Icon(Icons.qr_code, color: AppColors.tertiary), + title: Text(c.name), + subtitle: Text('班级码: ${c.classCode} · ${c.memberCount} 人'), + dense: true, + )).toList(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('关闭'), + ), + ], + ), + ); + } + void _showAssignTopicDialog(BuildContext context) { final titleController = TextEditingController(); final descController = TextEditingController(); diff --git a/app/lib/features/templates/views/template_gallery_page.dart b/app/lib/features/templates/views/template_gallery_page.dart index f92af62..c910e65 100644 --- a/app/lib/features/templates/views/template_gallery_page.dart +++ b/app/lib/features/templates/views/template_gallery_page.dart @@ -293,13 +293,22 @@ class _TemplateCard extends StatelessWidget { ), const SizedBox(height: 8), - // 标签 + // 标签(从模板 category 动态生成) Wrap( spacing: 6, runSpacing: 4, children: [ - _TagPill(label: '学生专属', bgColor: secondarySoft, textColor: AppColors.secondary), - _TagPill(label: '简约', bgColor: tertiarySoft, textColor: AppColors.tertiary), + if (template.category != null && template.category!.isNotEmpty) + _TagPill( + label: template.category!, + bgColor: secondarySoft, + textColor: AppColors.secondary, + ), + _TagPill( + label: template.isFree ? '免费' : '精品', + bgColor: template.isFree ? tertiarySoft : AppColors.roseSoftLight, + textColor: template.isFree ? AppColors.tertiary : AppColors.rose, + ), ], ), const SizedBox(height: 8),