diff --git a/app/lib/app.dart b/app/lib/app.dart index c08bd7b..6e36cc5 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -56,6 +56,7 @@ class NuanjiApp extends StatelessWidget { RepositoryProvider.value(value: authRepository), RepositoryProvider.value(value: journalRepository), RepositoryProvider.value(value: classRepository), + RepositoryProvider.value(value: settingsBloc), ], child: BlocProvider.value( value: authBloc, diff --git a/app/lib/core/routing/app_router.dart b/app/lib/core/routing/app_router.dart index bb69611..e5867a3 100644 --- a/app/lib/core/routing/app_router.dart +++ b/app/lib/core/routing/app_router.dart @@ -30,6 +30,7 @@ import '../../features/parent/views/parent_page.dart'; import '../../features/achievement/views/achievement_page.dart'; import '../../features/stickers/views/sticker_library_page.dart'; import '../../features/templates/views/template_gallery_page.dart'; +import '../../features/settings/views/settings_page.dart'; import '../../features/auth/bloc/auth_bloc.dart'; // Shell 分支键 @@ -192,6 +193,12 @@ GoRouter createAppRouter(AuthBloc authBloc) { parentNavigatorKey: _rootNavigatorKey, builder: (context, state) => const TemplateGalleryPage(), ), + GoRoute( + path: '/settings', + name: 'settings', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const SettingsPage(), + ), ], ); } diff --git a/app/lib/features/achievement/bloc/achievement_bloc.dart b/app/lib/features/achievement/bloc/achievement_bloc.dart new file mode 100644 index 0000000..3639915 --- /dev/null +++ b/app/lib/features/achievement/bloc/achievement_bloc.dart @@ -0,0 +1,108 @@ +// 成就 BLoC — 通过 API 加载成就列表 + +import 'package:flutter/material.dart'; +import 'package:nuanji_app/data/remote/api_client.dart'; + +// ===== 模型 ===== + +/// 成就数据 +class Achievement { + final String id; + final String code; + final String name; + final String? description; + final String? icon; + final String category; + final bool isUnlocked; + final DateTime? unlockedAt; + + const Achievement({ + required this.id, + required this.code, + required this.name, + this.description, + this.icon, + required this.category, + this.isUnlocked = false, + this.unlockedAt, + }); +} + +// ===== State ===== + +/// 成就页面状态 +class AchievementState { + final List achievements; + final bool isLoading; + final String? errorMessage; + + const AchievementState({ + this.achievements = const [], + this.isLoading = false, + this.errorMessage, + }); + + int get unlockedCount => + achievements.where((a) => a.isUnlocked).length; + + AchievementState copyWith({ + List? achievements, + bool? isLoading, + String? errorMessage, + }) => + AchievementState( + achievements: achievements ?? this.achievements, + isLoading: isLoading ?? this.isLoading, + errorMessage: errorMessage, + ); +} + +// ===== BLoC ===== + +/// 成就 BLoC — ChangeNotifier 模式 +class AchievementBloc extends ChangeNotifier { + final ApiClient _api; + AchievementState _state = const AchievementState(); + AchievementState get state => _state; + + AchievementBloc({required ApiClient api}) : _api = api; + + /// 加载成就列表 + void load() { + _state = _state.copyWith(isLoading: true); + notifyListeners(); + _fetchAchievements(); + } + + Future _fetchAchievements() async { + try { + final response = await _api.get('/diary/achievements'); + final body = response.data as Map; + final list = body['data'] as List? ?? []; + + final achievements = list.map((item) { + final m = item as Map; + return Achievement( + id: m['id'] as String, + code: m['code'] as String, + name: m['name'] as String, + description: m['description'] as String?, + icon: m['icon'] as String?, + category: m['category'] as String, + isUnlocked: m['is_unlocked'] as bool? ?? false, + unlockedAt: m['unlocked_at'] != null + ? DateTime.tryParse(m['unlocked_at'] as String) + : null, + ); + }).toList(); + + _state = _state.copyWith(isLoading: false, achievements: achievements); + } catch (e) { + _state = _state.copyWith( + isLoading: false, + errorMessage: '加载成就列表失败', + ); + } + notifyListeners(); + } +} diff --git a/app/lib/features/achievement/views/achievement_page.dart b/app/lib/features/achievement/views/achievement_page.dart index 71e39ba..f789cf8 100644 --- a/app/lib/features/achievement/views/achievement_page.dart +++ b/app/lib/features/achievement/views/achievement_page.dart @@ -1,92 +1,109 @@ // 成就页面 — 徽章收集展示 import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; - -/// 成就数据模型 -class Achievement { - final String id; - final String code; - final String name; - final String? description; - final String icon; - final String category; - final bool isUnlocked; - - const Achievement({ - required this.id, - required this.code, - required this.name, - this.description, - required this.icon, - required this.category, - this.isUnlocked = false, - }); -} +import 'package:nuanji_app/data/remote/api_client.dart'; +import '../bloc/achievement_bloc.dart'; /// 成就页面 — 徽章收集和展示 -class AchievementPage extends StatelessWidget { +class AchievementPage extends StatefulWidget { const AchievementPage({super.key}); - static const _achievements = [ - Achievement(id: '1', code: 'first_diary', name: '初次落笔', description: '写下第一篇日记', icon: '✏️', category: 'writing', isUnlocked: true), - Achievement(id: '2', code: 'streak_7', name: '坚持一周', description: '连续写日记 7 天', icon: '🔥', category: 'writing'), - Achievement(id: '3', code: 'streak_30', name: '月度达人', description: '连续写日记 30 天', icon: '💪', category: 'writing'), - Achievement(id: '4', code: 'sticker_collector', name: '贴纸收藏家', description: '收集 10 张贴纸', icon: '🎨', category: 'collection'), - Achievement(id: '5', code: 'social_butterfly', name: '分享之星', description: '分享 5 篇日记到班级', icon: '🌟', category: 'social'), - Achievement(id: '6', code: 'mood_tracker', name: '心情记录员', description: '连续记录心情 14 天', icon: '🌈', category: 'writing'), - Achievement(id: '7', code: 'early_bird', name: '早起日记', description: '在早上 7 点前写日记', icon: '🌅', category: 'special'), - Achievement(id: '8', code: 'artist', name: '小画家', description: '在日记中画 10 幅涂鸦', icon: '🖌️', category: 'collection'), - ]; + @override + State createState() => _AchievementPageState(); +} + +class _AchievementPageState extends State { + late final AchievementBloc _bloc; + + @override + void initState() { + super.initState(); + _bloc = AchievementBloc(api: context.read()); + _bloc.load(); + } + + @override + void dispose() { + _bloc.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final unlocked = _achievements.where((a) => a.isUnlocked).length; return Scaffold( - appBar: AppBar( - title: const Text('成就'), - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 进度概览 - _AchievementProgressCard( - unlocked: unlocked, - total: _achievements.length, - colorScheme: colorScheme, - ), - const SizedBox(height: 24), - Text( - '全部成就', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, + appBar: AppBar(title: const Text('成就')), + body: ListenableBuilder( + listenable: _bloc, + builder: (context, _) { + final state = _bloc.state; + + if (state.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.errorMessage != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48, color: colorScheme.error), + const SizedBox(height: 12), + Text(state.errorMessage!, style: theme.textTheme.bodyMedium), + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: _bloc.load, + child: const Text('重试'), + ), + ], ), - ), - const SizedBox(height: 12), - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 0.75, - ), - itemCount: _achievements.length, - itemBuilder: (context, index) { - return _AchievementCard( - achievement: _achievements[index], + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 进度概览 + _AchievementProgressCard( + unlocked: state.unlockedCount, + total: state.achievements.length, colorScheme: colorScheme, - ); - }, + ), + const SizedBox(height: 24), + Text( + '全部成就', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.75, + ), + itemCount: state.achievements.length, + itemBuilder: (context, index) { + return _AchievementCard( + achievement: state.achievements[index], + colorScheme: colorScheme, + ); + }, + ), + ], ), - ], - ), + ); + }, ), ); } @@ -194,7 +211,10 @@ class _AchievementCard extends StatelessWidget { ), alignment: Alignment.center, child: achievement.isUnlocked - ? Text(achievement.icon, style: const TextStyle(fontSize: 28)) + ? Text( + achievement.icon ?? '🏆', + style: const TextStyle(fontSize: 28), + ) : Icon( Icons.lock_outline, color: colorScheme.onSurface.withValues(alpha: 0.3), diff --git a/app/lib/features/mood/bloc/mood_bloc.dart b/app/lib/features/mood/bloc/mood_bloc.dart index e6dc9d0..71a3234 100644 --- a/app/lib/features/mood/bloc/mood_bloc.dart +++ b/app/lib/features/mood/bloc/mood_bloc.dart @@ -1,9 +1,10 @@ -// 心情 BLoC — 管理心情统计和趋势数据 +// 心情 BLoC — 通过 API 加载心情统计数据 import 'package:flutter/material.dart'; import 'package:nuanji_app/data/models/journal_entry.dart'; +import 'package:nuanji_app/data/remote/api_client.dart'; -// ===== 心情统计模型 ===== +// ===== 模型 ===== /// 心情统计数据 class MoodStats { @@ -33,35 +34,20 @@ class MoodCount { }); } -/// 心情趋势数据点 -class MoodTrendPoint { - final DateTime date; - final Mood mood; - final int journalCount; - - const MoodTrendPoint({ - required this.date, - required this.mood, - required this.journalCount, - }); -} - /// 统计周期 enum StatsPeriod { week, month, quarter } -// ===== 心情页面状态 ===== +// ===== State ===== /// 心情页面状态 class MoodState { final MoodStats stats; - final List trendData; final StatsPeriod selectedPeriod; final bool isLoading; final String? errorMessage; const MoodState({ this.stats = const MoodStats(), - this.trendData = const [], this.selectedPeriod = StatsPeriod.week, this.isLoading = false, this.errorMessage, @@ -69,55 +55,27 @@ class MoodState { MoodState copyWith({ MoodStats? stats, - List? trendData, StatsPeriod? selectedPeriod, bool? isLoading, String? errorMessage, }) => MoodState( stats: stats ?? this.stats, - trendData: trendData ?? this.trendData, selectedPeriod: selectedPeriod ?? this.selectedPeriod, isLoading: isLoading ?? this.isLoading, errorMessage: errorMessage, ); } -// ===== 心情 BLoC ===== +// ===== BLoC ===== +/// 心情统计 BLoC — ChangeNotifier 模式 class MoodBloc extends ChangeNotifier { + final ApiClient _api; MoodState _state = const MoodState(); MoodState get state => _state; - /// 切换统计周期 - void changePeriod(StatsPeriod period) { - _state = _state.copyWith(selectedPeriod: period, isLoading: true); - notifyListeners(); - _loadStats(); - } - - /// 加载统计数据 - Future _loadStats() async { - // Phase 1: 占位数据,待 API 集成后替换 - await Future.delayed(const Duration(milliseconds: 300)); - - _state = _state.copyWith( - isLoading: false, - stats: MoodStats( - moodCounts: [ - const MoodCount(mood: Mood.happy, count: 12, percentage: 40.0), - const MoodCount(mood: Mood.calm, count: 8, percentage: 26.7), - const MoodCount(mood: Mood.thinking, count: 5, percentage: 16.7), - const MoodCount(mood: Mood.sad, count: 3, percentage: 10.0), - const MoodCount(mood: Mood.angry, count: 2, percentage: 6.6), - ], - streakDays: 7, - totalJournals: 30, - dominantMood: Mood.happy, - ), - ); - notifyListeners(); - } + MoodBloc({required ApiClient api}) : _api = api; /// 初始加载 void load() { @@ -125,4 +83,66 @@ class MoodBloc extends ChangeNotifier { notifyListeners(); _loadStats(); } + + /// 切换统计周期 + void changePeriod(StatsPeriod period) { + if (period == _state.selectedPeriod) return; + _state = _state.copyWith(selectedPeriod: period, isLoading: true); + notifyListeners(); + _loadStats(); + } + + Future _loadStats() async { + try { + final periodStr = switch (_state.selectedPeriod) { + StatsPeriod.week => 'week', + StatsPeriod.month => 'month', + StatsPeriod.quarter => 'quarter', + }; + + final response = await _api.get( + '/diary/stats/mood', + queryParams: {'period': periodStr}, + ); + final body = response.data as Map; + final data = body['data'] as Map; + + final counts = (data['mood_counts'] as List? ?? []) + .map((item) { + final m = item as Map; + return MoodCount( + mood: Mood.values.firstWhere( + (v) => v.value == m['mood'], + orElse: () => Mood.happy, + ), + count: (m['count'] as num).toInt(), + percentage: (m['percentage'] as num).toDouble(), + ); + }) + .toList(); + + final dominant = data['dominant_mood'] != null + ? Mood.values.firstWhere( + (v) => v.value == data['dominant_mood'], + orElse: () => Mood.happy, + ) + : null; + + _state = _state.copyWith( + isLoading: false, + stats: MoodStats( + moodCounts: counts, + streakDays: (data['streak_days'] as num?)?.toInt() ?? 0, + totalJournals: (data['total_journals'] as num?)?.toInt() ?? 0, + dominantMood: dominant, + ), + ); + } catch (e) { + _state = _state.copyWith( + isLoading: false, + errorMessage: '加载统计数据失败', + ); + } + notifyListeners(); + } } diff --git a/app/lib/features/mood/views/mood_page.dart b/app/lib/features/mood/views/mood_page.dart index ae8329e..7b6a10d 100644 --- a/app/lib/features/mood/views/mood_page.dart +++ b/app/lib/features/mood/views/mood_page.dart @@ -2,11 +2,13 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; import 'package:nuanji_app/data/models/journal_entry.dart'; +import 'package:nuanji_app/data/remote/api_client.dart'; import '../bloc/mood_bloc.dart'; -/// 心情页面 — 统计卡片 + 心情分布饼图 + 趋势折线图 +/// 心情页面 — 统计卡片 + 心情分布饼图 + 详情列表 class MoodPage extends StatefulWidget { const MoodPage({super.key}); @@ -15,11 +17,12 @@ class MoodPage extends StatefulWidget { } class _MoodPageState extends State { - final _bloc = MoodBloc(); + late final MoodBloc _bloc; @override void initState() { super.initState(); + _bloc = MoodBloc(api: context.read()); _bloc.load(); } @@ -43,6 +46,24 @@ class _MoodPageState extends State { return const Center(child: CircularProgressIndicator()); } + if (state.errorMessage != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48, color: colorScheme.error), + const SizedBox(height: 12), + Text(state.errorMessage!, style: theme.textTheme.bodyMedium), + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: _bloc.load, + child: const Text('重试'), + ), + ], + ), + ); + } + return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( @@ -207,7 +228,8 @@ class _MoodDistributionChart extends StatelessWidget { child: PieChart( PieChartData( sections: moodCounts.map((mc) { - final color = AppColors.moodColors[mc.mood.value] ?? colorScheme.primary; + final color = + AppColors.moodColors[mc.mood.value] ?? colorScheme.primary; return PieChartSectionData( value: mc.count.toDouble(), color: color, @@ -239,7 +261,8 @@ class _MoodCountTile extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final color = AppColors.moodColors[mc.mood.value] ?? theme.colorScheme.primary; + final color = + AppColors.moodColors[mc.mood.value] ?? theme.colorScheme.primary; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), diff --git a/app/lib/features/profile/bloc/settings_bloc.dart b/app/lib/features/profile/bloc/settings_bloc.dart index 9c559e5..d1ea2f2 100644 --- a/app/lib/features/profile/bloc/settings_bloc.dart +++ b/app/lib/features/profile/bloc/settings_bloc.dart @@ -1,24 +1,13 @@ // 设置 BLoC — 主题切换 + 应用设置管理 +// +// ChangeNotifier 模式(同 MoodBloc),通过 ListenableBuilder 消费。 +// Phase 1: 内存态 + TODO 持久化到 SharedPreferences。 import 'package:flutter/material.dart'; -// ===== Events ===== - -sealed class SettingsEvent { - const SettingsEvent(); -} - -final class SettingsThemeChanged extends SettingsEvent { - final ThemeMode themeMode; - const SettingsThemeChanged(this.themeMode); -} - -final class SettingsLoad extends SettingsEvent { - const SettingsLoad(); -} - // ===== State ===== +/// 设置页面状态 class SettingsState { final ThemeMode themeMode; final bool isLoading; @@ -37,6 +26,7 @@ class SettingsState { // ===== BLoC ===== +/// 设置管理器 — 全局单例,在 NuanjiApp 中创建 class SettingsBloc extends ChangeNotifier { SettingsState _state = const SettingsState(); SettingsState get state => _state; @@ -45,7 +35,7 @@ class SettingsBloc extends ChangeNotifier { void changeTheme(ThemeMode mode) { _state = _state.copyWith(themeMode: mode); notifyListeners(); - // TODO: 持久化到 SharedPreferences/Isar + // TODO: 持久化到 SharedPreferences } /// 循环切换: system → light → dark → system diff --git a/app/lib/features/profile/views/profile_page.dart b/app/lib/features/profile/views/profile_page.dart index 5e03e45..00d8d66 100644 --- a/app/lib/features/profile/views/profile_page.dart +++ b/app/lib/features/profile/views/profile_page.dart @@ -111,11 +111,7 @@ class ProfilePage extends StatelessWidget { icon: Icons.settings_outlined, iconColor: colorScheme.onSurface.withValues(alpha: 0.5), title: '设置', - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('设置页面开发中')), - ); - }, + onTap: () => context.go('/settings'), ), const SizedBox(height: 16), diff --git a/app/lib/features/settings/views/settings_page.dart b/app/lib/features/settings/views/settings_page.dart new file mode 100644 index 0000000..18c7c63 --- /dev/null +++ b/app/lib/features/settings/views/settings_page.dart @@ -0,0 +1,499 @@ +// 设置页面 — 主题切换 + 关于 + 隐私政策 + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:nuanji_app/core/theme/app_colors.dart'; +import 'package:nuanji_app/features/profile/bloc/settings_bloc.dart'; + +/// 设置页面 +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('设置')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ===== 外观设置 ===== + _SectionHeader(title: '外观', theme: theme), + const SizedBox(height: 8), + const _ThemeSelectorCard(), + const SizedBox(height: 24), + + // ===== 关于 ===== + _SectionHeader(title: '关于', theme: theme), + const SizedBox(height: 8), + _AboutCard(colorScheme: colorScheme), + const SizedBox(height: 24), + + // ===== 法律信息 ===== + _SectionHeader(title: '法律信息', theme: theme), + const SizedBox(height: 8), + _LegalCard(colorScheme: colorScheme), + const SizedBox(height: 32), + + // ===== 底部版本号 ===== + Center( + child: Text( + '暖记 v1.0.0 (Phase 1)', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} + +// ===== 分区标题 ===== + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title, required this.theme}); + + final String title; + final ThemeData theme; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +// ===== 主题选择器卡片 ===== + +class _ThemeSelectorCard extends StatelessWidget { + const _ThemeSelectorCard(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final settingsBloc = context.read(); + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + side: BorderSide(color: colorScheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // 标题行 + Row( + children: [ + Icon(Icons.palette_outlined, color: colorScheme.primary), + const SizedBox(width: 12), + Text( + '主题模式', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 16), + + // 三选一 SegmentedButton(实时响应主题切换) + ListenableBuilder( + listenable: settingsBloc, + builder: (context, _) { + final current = settingsBloc.state.themeMode; + return SegmentedButton( + segments: const [ + ButtonSegment( + value: ThemeMode.system, + icon: Icon(Icons.brightness_auto), + label: Text('跟随系统'), + ), + ButtonSegment( + value: ThemeMode.light, + icon: Icon(Icons.light_mode), + label: Text('浅色'), + ), + ButtonSegment( + value: ThemeMode.dark, + icon: Icon(Icons.dark_mode), + label: Text('深色'), + ), + ], + selected: {current}, + onSelectionChanged: (modes) { + settingsBloc.changeTheme(modes.first); + }, + ); + }, + ), + ], + ), + ), + ); + } +} + +// ===== 关于卡片 ===== + +class _AboutCard extends StatelessWidget { + const _AboutCard({required this.colorScheme}); + + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + side: BorderSide(color: colorScheme.outlineVariant), + ), + child: Column( + children: [ + // 应用 Logo 信息 + Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppColors.accent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(16), + ), + alignment: Alignment.center, + child: const Text('📝', style: TextStyle(fontSize: 28)), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '暖记', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + '温暖治愈的手账日记', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ], + ), + ), + const Divider(height: 1, indent: 20, endIndent: 20), + _SettingsTile( + icon: Icons.info_outline, + iconColor: colorScheme.primary, + title: '版本信息', + subtitle: 'v1.0.0 · Phase 1', + onTap: () => _showAboutDialog(context), + ), + _SettingsTile( + icon: Icons.favorite_outline, + iconColor: AppColors.accent, + title: '给暖记评分', + subtitle: '你的鼓励是我们前进的动力', + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('应用商店评分功能即将上线')), + ); + }, + ), + _SettingsTile( + icon: Icons.feedback_outlined, + iconColor: AppColors.secondary, + title: '意见反馈', + subtitle: '告诉我们你的想法', + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('反馈功能即将上线')), + ); + }, + ), + ], + ), + ); + } + + void _showAboutDialog(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Row( + children: [ + Text('📝', style: TextStyle(fontSize: 24)), + SizedBox(width: 8), + Text('暖记'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('温暖治愈的手账日记 App'), + SizedBox(height: 8), + Text('版本: v1.0.0 (Phase 1)'), + SizedBox(height: 8), + Text('面向小学生首发,以手写/涂鸦为核心输入方式。'), + SizedBox(height: 16), + Text( + '© 2026 暖记团队', + style: TextStyle(fontSize: 12), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('确定'), + ), + ], + ), + ); + } +} + +// ===== 法律信息卡片 ===== + +class _LegalCard extends StatelessWidget { + const _LegalCard({required this.colorScheme}); + + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + side: BorderSide(color: colorScheme.outlineVariant), + ), + child: Column( + children: [ + _SettingsTile( + icon: Icons.shield_outlined, + iconColor: colorScheme.primary, + title: '隐私政策', + subtitle: '了解我们如何保护你的数据', + onTap: () => _showLegalDialog(context, page: _LegalPage.privacy), + ), + _SettingsTile( + icon: Icons.description_outlined, + iconColor: AppColors.tertiary, + title: '用户协议', + subtitle: '使用条款和服务说明', + onTap: () => _showLegalDialog(context, page: _LegalPage.terms), + ), + _SettingsTile( + icon: Icons.child_care_outlined, + iconColor: AppColors.secondary, + title: '儿童隐私保护', + subtitle: '我们如何保护未成年人的权益', + subtitleColor: AppColors.secondary, + onTap: () => _showLegalDialog(context, page: _LegalPage.child), + ), + ], + ), + ); + } + + void _showLegalDialog(BuildContext context, {required _LegalPage page}) { + final (title, icon, content) = switch (page) { + _LegalPage.privacy => ( + '隐私政策', + '🔒', + ''' +暖记隐私政策(最后更新:2026年6月) + +一、我们收集的信息 +• 昵称和年级(不收集真实姓名和身份证号) +• 日记内容(仅存储在你的设备上,云端加密存储) +• 心情记录(仅用于统计分析) + +二、信息使用 +• 提供日记手账服务 +• 班级互动功能 +• 个性化体验 + +三、信息保护 +• 所有数据传输采用 TLS 加密 +• 云端数据采用 AES-256-GCM 加密 +• 本地数据采用设备级加密 + +四、你的权利 +• 查阅、更正你的个人数据 +• 要求删除所有数据 +• 导出你的数据 + +五、儿童保护 +• 未满 14 岁需家长授权 +• 最小必要数据原则 +• 家长可管理孩子的数据 + +如有疑问,请联系:privacy@nuanji.app''', + ), + _LegalPage.terms => ( + '用户协议', + '📋', + ''' +暖记用户协议(最后更新:2026年6月) + +一、服务说明 +暖记是一款面向小学生的手账日记应用,提供日记书写、心情记录、班级互动等功能。 + +二、用户行为规范 +• 尊重他人,友善交流 +• 不发布不当内容 +• 遵守学校规章制度 + +三、内容归属 +• 用户创作的日记内容归用户所有 +• 暖记不会在未经授权的情况下使用用户内容 + +四、免责声明 +• 因不可抗力导致的服务中断不承担责任 +• 用户因不当使用导致的损失自行承担 + +五、协议修改 +• 修改协议将提前通知用户 +• 继续使用视为同意修改后的协议''', + ), + _LegalPage.child => ( + '儿童隐私保护', + '👶', + ''' +暖记特别重视儿童个人信息保护,严格遵守《儿童个人信息网络保护规定》。 + +一、家长授权 +• 未满 14 周岁的用户需家长同意后方可使用 +• 注册时需完成家长授权验证 +• 家长可随时撤回授权 + +二、最小必要原则 +• 仅收集提供服务必需的最少数据 +• 不收集真实姓名、身份证号等敏感信息 +• 不进行用户画像和个性化广告推送 + +三、数据安全 +• 采用多重加密保护儿童数据 +• 严格限制数据访问权限 +• 定期进行安全审计 + +四、家长权利 +• 查阅孩子的所有数据 +• 要求更正错误数据 +• 要求删除孩子数据(30天内完成) +• 导出孩子数据 + +五、账号注销 +• 注销后30天内删除所有关联数据 +• 包括日记、贴纸、成就等 +• 删除后不可恢复 + +六、内容安全 +• 自动过滤敏感内容 +• 老师可审核班级内容 +• 举报机制保护儿童安全 + +如需联系:child-safety@nuanji.app''', + ), + }; + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Row( + children: [ + Text(icon, style: const TextStyle(fontSize: 24)), + const SizedBox(width: 8), + Expanded(child: Text(title)), + ], + ), + content: SingleChildScrollView( + child: Text( + content.trim(), + style: const TextStyle(fontSize: 13, height: 1.6), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('我知道了'), + ), + ], + ), + ); + } +} + +enum _LegalPage { privacy, terms, child } + +// ===== 通用设置列表项 ===== + +class _SettingsTile extends StatelessWidget { + const _SettingsTile({ + required this.icon, + required this.iconColor, + required this.title, + required this.onTap, + this.subtitle, + this.subtitleColor, + }); + + final IconData icon; + final Color iconColor; + final String title; + final String? subtitle; + final Color? subtitleColor; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ListTile( + leading: Icon(icon, color: iconColor), + title: Text(title, style: theme.textTheme.bodyMedium), + subtitle: subtitle != null + ? Text( + subtitle!, + style: theme.textTheme.bodySmall?.copyWith( + color: subtitleColor ?? + theme.colorScheme.onSurface.withValues(alpha: 0.5), + ), + ) + : null, + trailing: const Icon(Icons.chevron_right, size: 20), + onTap: onTap, + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + ); + } +} diff --git a/app/lib/features/stickers/bloc/sticker_bloc.dart b/app/lib/features/stickers/bloc/sticker_bloc.dart new file mode 100644 index 0000000..04e121f --- /dev/null +++ b/app/lib/features/stickers/bloc/sticker_bloc.dart @@ -0,0 +1,181 @@ +// 贴纸 BLoC — 通过 API 加载贴纸包和贴纸数据 + +import 'package:flutter/material.dart'; +import 'package:nuanji_app/data/remote/api_client.dart'; + +// ===== 模型 ===== + +/// 贴纸包 +class StickerPack { + final String id; + final String name; + final String? description; + final String? coverImageUrl; + final int stickerCount; + final bool isFree; + final String? category; + + const StickerPack({ + required this.id, + required this.name, + this.description, + this.coverImageUrl, + this.stickerCount = 0, + this.isFree = true, + this.category, + }); + + /// 兼容旧 UI 的 emoji 封面(优先用 coverImageUrl,否则用默认) + String get displayCover => coverImageUrl ?? '🎨'; +} + +/// 单张贴纸 +class Sticker { + final String id; + final String packId; + final String name; + final String imageUrl; + final String? category; + + const Sticker({ + required this.id, + required this.packId, + required this.name, + required this.imageUrl, + this.category, + }); +} + +// ===== State ===== + +/// 贴纸页面状态 +class StickerState { + final List packs; + final String selectedCategory; + final bool isLoading; + final String? errorMessage; + + const StickerState({ + this.packs = const [], + this.selectedCategory = '全部', + this.isLoading = false, + this.errorMessage, + }); + + /// 按分类过滤贴纸包 + List get filteredPacks => selectedCategory == '全部' + ? packs + : packs.where((p) => p.category == selectedCategory).toList(); + + /// 所有分类(去重 + 加"全部") + List get categories { + final cats = packs + .map((p) => p.category) + .whereType() + .toSet() + .toList(); + return ['全部', ...cats]; + } + + StickerState copyWith({ + List? packs, + String? selectedCategory, + bool? isLoading, + String? errorMessage, + }) => + StickerState( + packs: packs ?? this.packs, + selectedCategory: selectedCategory ?? this.selectedCategory, + isLoading: isLoading ?? this.isLoading, + errorMessage: errorMessage, + ); +} + +// ===== BLoC ===== + +/// 贴纸 BLoC — ChangeNotifier 模式 +class StickerBloc extends ChangeNotifier { + final ApiClient _api; + StickerState _state = const StickerState(); + StickerState get state => _state; + + StickerBloc({required ApiClient api}) : _api = api; + + /// 加载贴纸包列表 + void load() { + _state = _state.copyWith(isLoading: true); + notifyListeners(); + _fetchPacks(); + } + + /// 选择分类 + void selectCategory(String category) { + _state = _state.copyWith(selectedCategory: category); + notifyListeners(); + } + + /// 按分类加载贴纸包 + void loadByCategory(String? category) { + _state = _state.copyWith(isLoading: true); + notifyListeners(); + _fetchPacks(category: category); + } + + Future _fetchPacks({String? category}) async { + try { + final queryParams = category != null && category != '全部' + ? {'category': category} + : null; + + final response = await _api.get( + '/diary/sticker-packs', + queryParams: queryParams, + ); + final body = response.data as Map; + final list = body['data'] as List? ?? []; + + final packs = list.map((item) { + final m = item as Map; + return StickerPack( + id: m['id'] as String, + name: m['name'] as String, + description: m['description'] as String?, + coverImageUrl: m['cover_image_url'] as String?, + stickerCount: (m['sticker_count'] as num?)?.toInt() ?? 0, + isFree: m['is_free'] as bool? ?? true, + category: m['category'] as String?, + ); + }).toList(); + + _state = _state.copyWith(isLoading: false, packs: packs); + } catch (e) { + _state = _state.copyWith( + isLoading: false, + errorMessage: '加载贴纸包失败', + ); + } + notifyListeners(); + } + + /// 获取贴纸包内的贴纸列表 + Future> fetchStickersInPack(String packId) async { + try { + final response = await _api.get('/diary/sticker-packs/$packId/stickers'); + final body = response.data as Map; + final list = body['data'] as List? ?? []; + + return list.map((item) { + final m = item as Map; + return Sticker( + id: m['id'] as String, + packId: m['pack_id'] as String, + name: m['name'] as String, + imageUrl: m['image_url'] as String, + category: m['category'] as String?, + ); + }).toList(); + } catch (e) { + return []; + } + } +} diff --git a/app/lib/features/stickers/views/sticker_library_page.dart b/app/lib/features/stickers/views/sticker_library_page.dart index dc691e7..5910213 100644 --- a/app/lib/features/stickers/views/sticker_library_page.dart +++ b/app/lib/features/stickers/views/sticker_library_page.dart @@ -1,26 +1,10 @@ -// 贴纸库页面 — 贴纸包浏览 + 贴纸网格 +// 贴纸库页面 — 贴纸包浏览 + 分类 Tab import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nuanji_app/core/theme/app_colors.dart'; - -/// 贴纸包数据模型 -class StickerPack { - final String id; - final String name; - final String? coverEmoji; - final int stickerCount; - final bool isFree; - final String? category; - - const StickerPack({ - required this.id, - required this.name, - this.coverEmoji, - this.stickerCount = 0, - this.isFree = true, - this.category, - }); -} +import 'package:nuanji_app/data/remote/api_client.dart'; +import '../bloc/sticker_bloc.dart'; /// 贴纸库页面 — 分类浏览贴纸包 class StickerLibraryPage extends StatefulWidget { @@ -30,93 +14,106 @@ class StickerLibraryPage extends StatefulWidget { State createState() => _StickerLibraryPageState(); } -class _StickerLibraryPageState extends State - with SingleTickerProviderStateMixin { - late TabController _tabController; - - // Phase 1 占位数据 - final _categories = ['全部', '动物', '食物', '自然', '节日', '表情']; - - final _packs = const [ - StickerPack(id: '1', name: '可爱猫咪', coverEmoji: '🐱', stickerCount: 24, isFree: true, category: '动物'), - StickerPack(id: '2', name: '小兔子系列', coverEmoji: '🐰', stickerCount: 20, isFree: true, category: '动物'), - StickerPack(id: '3', name: '甜品派对', coverEmoji: '🍰', stickerCount: 18, isFree: true, category: '食物'), - StickerPack(id: '4', name: '花朵合集', coverEmoji: '🌸', stickerCount: 22, isFree: true, category: '自然'), - StickerPack(id: '5', name: '夏日清凉', coverEmoji: '🍉', stickerCount: 16, isFree: true, category: '食物'), - StickerPack(id: '6', name: '星空物语', coverEmoji: '⭐', stickerCount: 20, isFree: false, category: '自然'), - StickerPack(id: '7', name: '开心表情', coverEmoji: '😄', stickerCount: 30, isFree: true, category: '表情'), - StickerPack(id: '8', name: '新年快乐', coverEmoji: '🎉', stickerCount: 15, isFree: false, category: '节日'), - ]; +class _StickerLibraryPageState extends State { + late final StickerBloc _bloc; @override void initState() { super.initState(); - _tabController = TabController(length: _categories.length, vsync: this); + _bloc = StickerBloc(api: context.read()); + _bloc.load(); } @override void dispose() { - _tabController.dispose(); + _bloc.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colorScheme = Theme.of(context).colorScheme; return Scaffold( - appBar: AppBar( - title: const Text('贴纸库'), - bottom: TabBar( - controller: _tabController, - isScrollable: true, - tabAlignment: TabAlignment.start, - tabs: _categories.map((c) => Tab(text: c)).toList(), - ), + appBar: AppBar(title: const Text('贴纸库')), + body: ListenableBuilder( + listenable: _bloc, + builder: (context, _) { + final state = _bloc.state; + + if (state.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.errorMessage != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48, color: colorScheme.error), + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: _bloc.load, + child: const Text('重试'), + ), + ], + ), + ); + } + + final categories = state.categories; + + return Column( + children: [ + // 分类选择器(横向滚动 Chips) + SizedBox( + height: 48, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: categories.map((cat) { + final isSelected = cat == state.selectedCategory; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + selected: isSelected, + label: Text(cat), + onSelected: (_) => _bloc.selectCategory(cat), + selectedColor: colorScheme.primaryContainer, + checkmarkColor: colorScheme.primary, + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 8), + + // 贴纸包网格 + Expanded( + child: state.filteredPacks.isEmpty + ? const Center(child: Text('暂无贴纸包')) + : GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.85, + ), + itemCount: state.filteredPacks.length, + itemBuilder: (context, index) { + return _StickerPackCard( + pack: state.filteredPacks[index], + colorScheme: colorScheme, + ); + }, + ), + ), + ], + ); + }, ), - body: TabBarView( - controller: _tabController, - children: _categories.map((category) { - final filtered = category == '全部' - ? _packs - : _packs.where((p) => p.category == category).toList(); - return _StickerPackGrid(packs: filtered, colorScheme: colorScheme); - }).toList(), - ), - ); - } -} - -/// 贴纸包网格 -class _StickerPackGrid extends StatelessWidget { - const _StickerPackGrid({ - required this.packs, - required this.colorScheme, - }); - - final List packs; - final ColorScheme colorScheme; - - @override - Widget build(BuildContext context) { - if (packs.isEmpty) { - return const Center(child: Text('暂无贴纸包')); - } - - return GridView.builder( - padding: const EdgeInsets.all(16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 0.85, - ), - itemCount: packs.length, - itemBuilder: (context, index) { - final pack = packs[index]; - return _StickerPackCard(pack: pack, colorScheme: colorScheme); - }, ); } } @@ -143,7 +140,6 @@ class _StickerPackCard extends StatelessWidget { ), child: InkWell( onTap: () { - // Phase 1: 展示贴纸包详情页(待实现) ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('打开贴纸包: ${pack.name}')), ); @@ -164,7 +160,7 @@ class _StickerPackCard extends StatelessWidget { ), alignment: Alignment.center, child: Text( - pack.coverEmoji ?? '🎨', + pack.coverImageUrl != null ? '🎨' : pack.displayCover, style: const TextStyle(fontSize: 32), ), ), @@ -192,7 +188,8 @@ class _StickerPackCard extends StatelessWidget { if (!pack.isFree) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: AppColors.accent.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(6), diff --git a/app/lib/features/templates/bloc/template_bloc.dart b/app/lib/features/templates/bloc/template_bloc.dart new file mode 100644 index 0000000..7848566 --- /dev/null +++ b/app/lib/features/templates/bloc/template_bloc.dart @@ -0,0 +1,135 @@ +// 模板 BLoC — 通过 API 加载模板列表 + +import 'package:flutter/material.dart'; +import 'package:nuanji_app/data/remote/api_client.dart'; + +// ===== 模型 ===== + +/// 日记模板 +class Template { + final String id; + final String name; + final String? description; + final String? previewUrl; + final Map? templateData; + final String? category; + final bool isFree; + + /// 用于 UI 显示的 emoji(基于 category 推导) + String get emoji => switch (category) { + '日常' => '☀️', + '旅行' => '🗺️', + '校园' => '📚', + '节日' => '🎄', + '创意' => '✨', + _ => '📝', + }; + + const Template({ + required this.id, + required this.name, + this.description, + this.previewUrl, + this.templateData, + this.category, + this.isFree = true, + }); +} + +// ===== State ===== + +/// 模板页面状态 +class TemplateState { + final List