diff --git a/lib/src/features/journey/history_screen.dart b/lib/src/features/journey/history_screen.dart index 00bfaee..e86db9f 100644 --- a/lib/src/features/journey/history_screen.dart +++ b/lib/src/features/journey/history_screen.dart @@ -4,6 +4,7 @@ import '../../application/focus_seed_controller.dart'; import '../../l10n/l10n.dart'; import '../../models/focus_session.dart'; import '../../models/reflection_entry.dart'; +import '../../widgets/seed_illustration.dart'; import '../../widgets/surface_panel.dart'; enum _HistoryView { focus, notes } @@ -37,28 +38,11 @@ class _HistoryScreenState extends State { return ListView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 28), children: [ - SizedBox( - width: double.infinity, - child: SegmentedButton<_HistoryView>( - showSelectedIcon: false, - segments: [ - ButtonSegment( - value: _HistoryView.focus, - icon: const Icon(Icons.timer_outlined), - label: Text(l10n.focusHistory), - ), - ButtonSegment( - value: _HistoryView.notes, - icon: const Icon(Icons.edit_note_rounded), - label: Text(l10n.notesHistory), - ), - ], - selected: {_view}, - onSelectionChanged: - (selection) => setState(() => _view = selection.first), - ), + _HistorySwitch( + value: _view, + onChanged: (value) => setState(() => _view = value), ), - const SizedBox(height: 14), + const SizedBox(height: 10), AnimatedSwitcher( duration: const Duration(milliseconds: 220), switchInCurve: Curves.easeOutCubic, @@ -68,10 +52,12 @@ class _HistoryScreenState extends State { ? _FocusHistoryList( key: const ValueKey('focus-history'), sessions: focusSessions, + onStartToday: () => Navigator.of(context).pop(), ) : _ReflectionHistoryList( key: const ValueKey('reflection-history'), reflections: reflections, + onStartToday: () => Navigator.of(context).pop(), ), ), ], @@ -83,10 +69,133 @@ class _HistoryScreenState extends State { } } +class _HistorySwitch extends StatelessWidget { + const _HistorySwitch({required this.value, required this.onChanged}); + + final _HistoryView value; + final ValueChanged<_HistoryView> onChanged; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: scheme.surfaceContainerLow.withValues(alpha: 0.70), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: scheme.outlineVariant.withValues(alpha: 0.70), + ), + ), + child: Padding( + padding: const EdgeInsets.all(5), + child: Row( + children: [ + Expanded( + child: _HistorySwitchItem( + selected: value == _HistoryView.focus, + icon: Icons.timer_outlined, + label: l10n.focusHistory, + onTap: () => onChanged(_HistoryView.focus), + ), + ), + const SizedBox(width: 6), + Expanded( + child: _HistorySwitchItem( + selected: value == _HistoryView.notes, + icon: Icons.edit_note_rounded, + label: l10n.notesHistory, + onTap: () => onChanged(_HistoryView.notes), + ), + ), + ], + ), + ), + ); + } +} + +class _HistorySwitchItem extends StatelessWidget { + const _HistorySwitchItem({ + required this.selected, + required this.icon, + required this.label, + required this.onTap, + }); + + final bool selected; + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: + selected + ? scheme.primary.withValues(alpha: 0.16) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + boxShadow: + selected + ? [ + BoxShadow( + color: scheme.primary.withValues(alpha: 0.16), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ] + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 19, + color: selected ? scheme.primary : scheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith( + color: + selected ? scheme.onSurface : scheme.onSurfaceVariant, + fontWeight: selected ? FontWeight.w900 : FontWeight.w700, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + class _FocusHistoryList extends StatelessWidget { - const _FocusHistoryList({required this.sessions, super.key}); + const _FocusHistoryList({ + required this.sessions, + required this.onStartToday, + super.key, + }); final List sessions; + final VoidCallback onStartToday; @override Widget build(BuildContext context) { @@ -95,38 +204,38 @@ class _FocusHistoryList extends StatelessWidget { return _EmptyHistory( title: l10n.emptyFocusHistoryTitle, body: l10n.emptyFocusHistoryBody, + tone: SeedIllustrationTone.archive, + onStartToday: onStartToday, ); } - return SurfacePanel( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), - child: Column( - children: [ - for (var index = 0; index < sessions.length; index++) ...[ - if (index > 0) const Divider(), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.timer_outlined), - title: Text(l10n.focusMinutes(sessions[index].minutes)), - subtitle: Text( - localizedReadableDate(context, sessions[index].completedAt), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: Text( - localizedTime(context, sessions[index].completedAt), - ), + return Column( + children: [ + for (var index = 0; index < sessions.length; index++) ...[ + if (index > 0) const SizedBox(height: 8), + _HistoryTimelineTile( + leading: Icons.timer_outlined, + title: l10n.focusMinutes(sessions[index].minutes), + subtitle: localizedReadableDate( + context, + sessions[index].completedAt, ), - ], + trailing: localizedTime(context, sessions[index].completedAt), + ), ], - ), + ], ); } } class _ReflectionHistoryList extends StatelessWidget { - const _ReflectionHistoryList({required this.reflections, super.key}); + const _ReflectionHistoryList({ + required this.reflections, + required this.onStartToday, + super.key, + }); final List reflections; + final VoidCallback onStartToday; @override Widget build(BuildContext context) { @@ -135,23 +244,164 @@ class _ReflectionHistoryList extends StatelessWidget { return _EmptyHistory( title: l10n.emptyNotesHistoryTitle, body: l10n.emptyNotesHistoryBody, + tone: SeedIllustrationTone.data, + onStartToday: onStartToday, ); } + return Column( + children: [ + for (var index = 0; index < reflections.length; index++) ...[ + if (index > 0) const SizedBox(height: 8), + _HistoryTimelineTile( + leading: reflections[index].mood.icon, + title: reflections[index].note, + subtitle: reflections[index].dateKey, + ), + ], + ], + ); + } +} + +class _EmptyHistory extends StatelessWidget { + const _EmptyHistory({ + required this.title, + required this.body, + required this.tone, + required this.onStartToday, + }); + + final String title; + final String body; + final SeedIllustrationTone tone; + final VoidCallback onStartToday; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final minHeight = MediaQuery.sizeOf(context).height * 0.62; + return SizedBox( + height: minHeight.clamp(430, 620).toDouble(), + child: Center( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment.topCenter, + radius: 1.1, + colors: [ + scheme.primary.withValues(alpha: 0.16), + scheme.surfaceContainerLowest.withValues(alpha: 0.76), + Colors.transparent, + ], + ), + borderRadius: BorderRadius.circular(18), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 26, 18, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SeedIllustration(size: 142, tone: tone, hero: true), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 310), + child: Text( + body, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 18), + FilledButton.icon( + onPressed: onStartToday, + icon: const Icon(Icons.arrow_back_rounded), + label: Text(context.l10n.navToday), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _HistoryTimelineTile extends StatelessWidget { + const _HistoryTimelineTile({ + required this.leading, + required this.title, + required this.subtitle, + this.trailing, + }); + + final IconData leading; + final String title; + final String subtitle; + final String? trailing; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; return SurfacePanel( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), - child: Column( + padding: const EdgeInsets.fromLTRB(13, 12, 14, 12), + borderColor: scheme.outlineVariant.withValues(alpha: 0.60), + child: Row( children: [ - for (var index = 0; index < reflections.length; index++) ...[ - if (index > 0) const Divider(), - ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(reflections[index].mood.icon), - title: Text( - reflections[index].note, - maxLines: 3, - overflow: TextOverflow.ellipsis, + DecoratedBox( + decoration: BoxDecoration( + color: scheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: SizedBox.square( + dimension: 42, + child: Icon(leading, color: scheme.primary, size: 21), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 3), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 10), + Text( + trailing!, + style: theme.textTheme.labelMedium?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w800, ), - subtitle: Text(reflections[index].dateKey), ), ], ], @@ -159,43 +409,3 @@ class _ReflectionHistoryList extends StatelessWidget { ); } } - -class _EmptyHistory extends StatelessWidget { - const _EmptyHistory({required this.title, required this.body}); - - final String title; - final String body; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return SurfacePanel( - padding: const EdgeInsets.all(22), - child: Column( - children: [ - Icon( - Icons.auto_graph_rounded, - size: 38, - color: theme.colorScheme.primary, - ), - const SizedBox(height: 10), - Text( - title, - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 6), - Text( - body, - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - } -} diff --git a/lib/src/features/journey/widgets/journey_overview_panel.dart b/lib/src/features/journey/widgets/journey_overview_panel.dart index a66042d..84b4185 100644 --- a/lib/src/features/journey/widgets/journey_overview_panel.dart +++ b/lib/src/features/journey/widgets/journey_overview_panel.dart @@ -37,17 +37,16 @@ class JourneyOverviewPanel extends StatelessWidget { return SurfacePanel( elevated: true, padding: EdgeInsets.zero, - color: scheme.surfaceContainerLowest, + borderColor: scheme.outlineVariant.withValues(alpha: 0.45), child: Container( - padding: const EdgeInsets.fromLTRB(18, 16, 16, 18), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - scheme.primary.withValues(alpha: 0.12), - scheme.surfaceContainerLowest, - scheme.secondary.withValues(alpha: 0.07), + scheme.primary.withValues(alpha: 0.20), + scheme.surfaceContainerLowest.withValues(alpha: 0.96), + scheme.secondary.withValues(alpha: 0.14), ], ), ), @@ -64,23 +63,46 @@ class JourneyOverviewPanel extends StatelessWidget { onReflect: onReflect, ); if (compact) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Stack( children: [ - const Align( - alignment: Alignment.centerRight, - child: SeedIllustration(size: 88), + const Positioned( + right: -18, + top: -20, + child: Opacity( + opacity: 0.58, + child: SeedIllustration(size: 128, hero: true), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 17, 106, 18), + child: textContent, ), - const SizedBox(height: 8), - textContent, ], ); } - return Row( + return Stack( children: [ - Expanded(child: textContent), - const SizedBox(width: 12), - const SeedIllustration(size: 112), + Positioned( + right: -28, + top: -22, + bottom: -18, + child: Opacity( + opacity: 0.96, + child: SeedIllustration( + size: constraints.maxWidth.clamp(146, 184).toDouble(), + hero: true, + ), + ), + ), + Positioned( + right: 92, + bottom: 18, + child: _ProgressBadge(completed: completed), + ), + Padding( + padding: const EdgeInsets.fromLTRB(18, 17, 168, 18), + child: textContent, + ), ], ); }, @@ -140,16 +162,9 @@ class _OverviewText extends StatelessWidget { color: scheme.onSurfaceVariant, ), ), - const SizedBox(height: 14), - ClipRRect( - borderRadius: BorderRadius.circular(99), - child: LinearProgressIndicator( - minHeight: 8, - value: progressValue, - backgroundColor: scheme.surfaceContainerHigh, - ), - ), - const SizedBox(height: 10), + const SizedBox(height: 16), + _ProgressSeeds(completed: completed, progressValue: progressValue), + const SizedBox(height: 12), Row( children: [ Expanded( @@ -172,3 +187,86 @@ class _OverviewText extends StatelessWidget { ); } } + +class _ProgressSeeds extends StatelessWidget { + const _ProgressSeeds({required this.completed, required this.progressValue}); + + final int completed; + final double progressValue; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Semantics( + value: '${(progressValue * 100).round()}%', + child: Row( + children: List.generate(4, (index) { + final filled = index < completed; + return Expanded( + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + height: 10, + margin: EdgeInsets.only(right: index == 3 ? 0 : 7), + decoration: BoxDecoration( + color: + filled + ? scheme.primary + : scheme.surfaceContainerHigh.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(99), + boxShadow: + filled + ? [ + BoxShadow( + color: scheme.primary.withValues(alpha: 0.28), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ] + : null, + ), + ), + ); + }), + ), + ); + } +} + +class _ProgressBadge extends StatelessWidget { + const _ProgressBadge({required this.completed}); + + final int completed; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); + return DecoratedBox( + decoration: BoxDecoration( + color: scheme.surfaceContainerLowest.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(99), + border: Border.all( + color: scheme.outlineVariant.withValues(alpha: 0.65), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.10), + blurRadius: 14, + offset: const Offset(0, 7), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 7), + child: Text( + '$completed/4', + style: theme.textTheme.labelLarge?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.w900, + ), + ), + ), + ); + } +} diff --git a/lib/src/features/me/me_screen.dart b/lib/src/features/me/me_screen.dart index 6bb5a4f..0382068 100644 --- a/lib/src/features/me/me_screen.dart +++ b/lib/src/features/me/me_screen.dart @@ -47,119 +47,16 @@ class MeScreen extends StatelessWidget { habits: state.habits.length, reflections: state.reflections.length, ), - const SizedBox(height: 18), - _SectionHeader( - title: l10n.preferencesTitle, - detail: l10n.preferencesDetail, - ), - const SizedBox(height: 8), - SurfacePanel( - child: SizedBox( - width: double.infinity, - child: SegmentedButton( - showSelectedIcon: false, - segments: [ - ButtonSegment( - value: AppearancePreference.system, - icon: const Icon(Icons.brightness_auto_outlined), - label: Text(l10n.system), - ), - ButtonSegment( - value: AppearancePreference.light, - icon: const Icon(Icons.light_mode_outlined), - label: Text(l10n.light), - ), - ButtonSegment( - value: AppearancePreference.dark, - icon: const Icon(Icons.dark_mode_outlined), - label: Text(l10n.dark), - ), - ], - selected: {appearance}, - onSelectionChanged: - (selection) => onAppearanceChanged(selection.first), - ), - ), - ), - if (showLanguageTools) ...[ - const SizedBox(height: 16), - _SectionHeader( - title: l10n.language, - detail: l10n.developmentOnly, - ), - const SizedBox(height: 8), - SurfacePanel( - child: SizedBox( - width: double.infinity, - child: SegmentedButton( - showSelectedIcon: false, - segments: [ - ButtonSegment( - value: LanguagePreference.defaultLocale, - icon: const Icon(Icons.tune_rounded), - label: Text(l10n.languageDefault), - ), - ButtonSegment( - value: LanguagePreference.english, - icon: const Icon(Icons.language_rounded), - label: Text(l10n.languageEnglish), - ), - ButtonSegment( - value: LanguagePreference.chinese, - icon: const Icon(Icons.translate_rounded), - label: Text(l10n.languageChinese), - ), - ], - selected: {language}, - onSelectionChanged: - (selection) => onLanguageChanged(selection.first), - ), - ), - ), - ], - const SizedBox(height: 18), - _SectionHeader( - title: l10n.localData, - detail: l10n.localDataDetail, - ), - const SizedBox(height: 8), - _ActionGrid( - children: [ - _ActionCard( - icon: Icons.cleaning_services_outlined, - title: l10n.clearToday, - subtitle: l10n.clearTodaySubtitle, - onTap: - () => confirmClearToday(context, () async { - final cleared = await controller.clearToday(); - if (context.mounted && cleared) { - showSnack(context, l10n.todayDataCleared); - } - }), - ), - _ActionCard( - icon: Icons.privacy_tip_outlined, - title: l10n.privacyTitle, - subtitle: l10n.privacySubtitle, - onTap: () => showPrivacySheet(context), - ), - ], - ), - const SizedBox(height: 12), - _ActionCard( - icon: Icons.delete_forever_outlined, - title: l10n.deleteAllLocalData, - subtitle: l10n.deleteAllLocalDataSubtitle, - destructive: true, - fullWidth: true, - onTap: - () => confirmClearData(context, () async { - final cleared = await controller.clearData(); - if (context.mounted && cleared) { - showSnack(context, l10n.localDataCleared); - } - }), + const SizedBox(height: 16), + _PreferenceDeck( + appearance: appearance, + onAppearanceChanged: onAppearanceChanged, + language: language, + showLanguageTools: showLanguageTools, + onLanguageChanged: onLanguageChanged, ), + const SizedBox(height: 16), + _DataCarePanel(controller: controller), const SizedBox(height: 18), Text( 'Focus Seed 1.0.2', @@ -195,81 +92,84 @@ class _PersonalGardenCard extends StatelessWidget { return SurfacePanel( elevated: true, padding: EdgeInsets.zero, + borderColor: scheme.outlineVariant.withValues(alpha: 0.42), child: Container( - padding: const EdgeInsets.fromLTRB(16, 16, 14, 16), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - scheme.primary.withValues(alpha: 0.10), + scheme.tertiary.withValues(alpha: 0.16), scheme.surfaceContainerLowest, - scheme.tertiary.withValues(alpha: 0.07), + scheme.primary.withValues(alpha: 0.14), ], ), ), - child: Column( + child: Stack( children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const Positioned( + right: -24, + top: -24, + child: SeedIllustration( + size: 154, + tone: SeedIllustrationTone.data, + hero: true, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 17, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 250), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.personalGardenTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 6), + Text( + l10n.personalGardenSubtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(height: 28), + Wrap( + spacing: 8, + runSpacing: 8, children: [ - Text( - l10n.personalGardenTitle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w900, - ), + _MetricPill( + icon: Icons.timer_outlined, + label: l10n.focusDataLabel, + value: focusSessions, ), - const SizedBox(height: 5), - Text( - l10n.personalGardenSubtitle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), + _MetricPill( + icon: Icons.checklist_rounded, + label: l10n.habitsDataLabel, + value: habits, + ), + _MetricPill( + icon: Icons.edit_note_rounded, + label: l10n.notesDataLabel, + value: reflections, ), ], ), - ), - const SizedBox(width: 10), - const SeedIllustration( - size: 88, - tone: SeedIllustrationTone.data, - ), - ], - ), - const SizedBox(height: 14), - Row( - children: [ - Expanded( - child: _DataMetric( - icon: Icons.timer_outlined, - label: l10n.focusDataLabel, - value: focusSessions, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _DataMetric( - icon: Icons.checklist_rounded, - label: l10n.habitsDataLabel, - value: habits, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _DataMetric( - icon: Icons.edit_note_rounded, - label: l10n.notesDataLabel, - value: reflections, - ), - ), - ], + ], + ), ), ], ), @@ -278,8 +178,8 @@ class _PersonalGardenCard extends StatelessWidget { } } -class _DataMetric extends StatelessWidget { - const _DataMetric({ +class _MetricPill extends StatelessWidget { + const _MetricPill({ required this.icon, required this.label, required this.value, @@ -293,151 +193,485 @@ class _DataMetric extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + return DecoratedBox( decoration: BoxDecoration( - color: scheme.surfaceContainerLowest.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.6)), + color: scheme.surfaceContainerLowest.withValues(alpha: 0.76), + borderRadius: BorderRadius.circular(99), + border: Border.all( + color: scheme.outlineVariant.withValues(alpha: 0.58), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), - child: Row( - children: [ - Icon(icon, color: scheme.primary, size: 18), - const SizedBox(width: 7), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TweenAnimationBuilder( - tween: Tween(end: value.toDouble()), - duration: const Duration(milliseconds: 260), - curve: Curves.easeOutCubic, - builder: - (context, animatedValue, _) => Text( - animatedValue.round().toString(), - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w900, - ), - ), - ), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: scheme.primary, size: 17), + const SizedBox(width: 7), + TweenAnimationBuilder( + tween: Tween(end: value.toDouble()), + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + builder: + (context, animatedValue, _) => Text( + animatedValue.round().toString(), + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w900, + ), ), + ), + const SizedBox(width: 4), + Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + } +} + +class _PreferenceDeck extends StatelessWidget { + const _PreferenceDeck({ + required this.appearance, + required this.onAppearanceChanged, + required this.language, + required this.showLanguageTools, + required this.onLanguageChanged, + }); + + final AppearancePreference appearance; + final ValueChanged onAppearanceChanged; + final LanguagePreference language; + final bool showLanguageTools; + final ValueChanged onLanguageChanged; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; + return SurfacePanel( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 15), + borderColor: scheme.outlineVariant.withValues(alpha: 0.55), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader( + title: l10n.preferencesTitle, + detail: l10n.preferencesDetail, + ), + const SizedBox(height: 12), + _PreferenceSwitcher( + value: appearance, + onChanged: onAppearanceChanged, + choices: [ + _PreferenceChoice( + value: AppearancePreference.system, + icon: Icons.brightness_auto_outlined, + label: l10n.system, + ), + _PreferenceChoice( + value: AppearancePreference.light, + icon: Icons.light_mode_outlined, + label: l10n.light, + ), + _PreferenceChoice( + value: AppearancePreference.dark, + icon: Icons.dark_mode_outlined, + label: l10n.dark, + ), + ], + ), + if (showLanguageTools) ...[ + const SizedBox(height: 16), + Divider(color: scheme.outlineVariant.withValues(alpha: 0.62)), + const SizedBox(height: 12), + _SectionHeader(title: l10n.language, detail: l10n.developmentOnly), + const SizedBox(height: 12), + _PreferenceSwitcher( + value: language, + onChanged: onLanguageChanged, + choices: [ + _PreferenceChoice( + value: LanguagePreference.defaultLocale, + icon: Icons.tune_rounded, + label: l10n.languageDefault, + ), + _PreferenceChoice( + value: LanguagePreference.english, + icon: Icons.language_rounded, + label: l10n.languageEnglish, + ), + _PreferenceChoice( + value: LanguagePreference.chinese, + icon: Icons.translate_rounded, + label: l10n.languageChinese, ), ], ), - ), + ], ], ), ); } } -class _ActionGrid extends StatelessWidget { - const _ActionGrid({required this.children}); +class _PreferenceChoice { + const _PreferenceChoice({ + required this.value, + required this.icon, + required this.label, + }); - final List children; + final T value; + final IconData icon; + final String label; +} + +class _PreferenceSwitcher extends StatelessWidget { + const _PreferenceSwitcher({ + required this.value, + required this.choices, + required this.onChanged, + }); + + final T value; + final List<_PreferenceChoice> choices; + final ValueChanged onChanged; @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final compact = constraints.maxWidth < 350; - if (compact) { - return Column( - children: [ - for (var index = 0; index < children.length; index++) ...[ - if (index > 0) const SizedBox(height: 10), - children[index], - ], + final scheme = Theme.of(context).colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh.withValues(alpha: 0.48), + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: Row( + children: [ + for (final choice in choices) ...[ + Expanded( + child: _PreferenceChip( + selected: choice.value == value, + icon: choice.icon, + label: choice.label, + onTap: () => onChanged(choice.value), + ), + ), + if (choice != choices.last) const SizedBox(width: 4), ], - ); - } - return GridView.count( - crossAxisCount: 2, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - crossAxisSpacing: 10, - childAspectRatio: 1.22, - children: children, - ); - }, + ], + ), + ), ); } } -class _ActionCard extends StatelessWidget { - const _ActionCard({ +class _PreferenceChip extends StatelessWidget { + const _PreferenceChip({ + required this.selected, + required this.icon, + required this.label, + required this.onTap, + }); + + final bool selected; + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 11), + decoration: BoxDecoration( + color: + selected + ? scheme.primary.withValues(alpha: 0.15) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: + selected + ? Border.all(color: scheme.primary.withValues(alpha: 0.28)) + : null, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: selected ? scheme.primary : scheme.onSurfaceVariant, + size: 21, + ), + const SizedBox(height: 5), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium?.copyWith( + color: selected ? scheme.onSurface : scheme.onSurfaceVariant, + fontWeight: selected ? FontWeight.w900 : FontWeight.w700, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _DataCarePanel extends StatelessWidget { + const _DataCarePanel({required this.controller}); + + final FocusSeedController controller; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return SurfacePanel( + elevated: true, + padding: EdgeInsets.zero, + borderColor: scheme.outlineVariant.withValues(alpha: 0.42), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + scheme.primary.withValues(alpha: 0.10), + scheme.surfaceContainerLowest, + scheme.error.withValues(alpha: 0.05), + ], + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), + child: Column( + children: [ + Row( + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: scheme.primary.withValues(alpha: 0.13), + borderRadius: BorderRadius.circular(12), + ), + child: SizedBox.square( + dimension: 42, + child: Icon( + Icons.verified_user_outlined, + color: scheme.primary, + size: 22, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.localData, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 2), + Text( + l10n.localDataDetail, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + _DataActionTile( + icon: Icons.cleaning_services_outlined, + title: l10n.clearToday, + subtitle: l10n.clearTodaySubtitle, + onTap: + () => confirmClearToday(context, () async { + final cleared = await controller.clearToday(); + if (context.mounted && cleared) { + showSnack(context, l10n.todayDataCleared); + } + }), + ), + const SizedBox(height: 8), + _DataActionTile( + icon: Icons.privacy_tip_outlined, + title: l10n.privacyTitle, + subtitle: l10n.privacySubtitle, + onTap: () => showPrivacySheet(context), + ), + const SizedBox(height: 10), + _DangerActionTile( + title: l10n.deleteAllLocalData, + subtitle: l10n.deleteAllLocalDataSubtitle, + onTap: + () => confirmClearData(context, () async { + final cleared = await controller.clearData(); + if (context.mounted && cleared) { + showSnack(context, l10n.localDataCleared); + } + }), + ), + ], + ), + ), + ), + ); + } +} + +class _DataActionTile extends StatelessWidget { + const _DataActionTile({ required this.icon, required this.title, required this.subtitle, required this.onTap, - this.destructive = false, - this.fullWidth = false, }); final IconData icon; final String title; final String subtitle; final VoidCallback onTap; - final bool destructive; - final bool fullWidth; @override Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; - final accent = destructive ? scheme.error : scheme.primary; - return SurfacePanel( - onTap: onTap, - padding: const EdgeInsets.all(14), - borderColor: destructive ? scheme.error.withValues(alpha: 0.38) : null, - color: - destructive - ? scheme.errorContainer.withValues(alpha: 0.22) - : scheme.surfaceContainerLowest, - child: SizedBox( - width: fullWidth ? double.infinity : null, - height: fullWidth ? 108 : 118, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), + return Material( + color: scheme.surfaceContainerLow.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 11, 12, 11), + child: Row( + children: [ + Icon(icon, color: scheme.primary, size: 22), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), ), - child: Icon(icon, color: accent, size: 21), - ), - const Spacer(), - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall?.copyWith( - color: destructive ? scheme.error : null, - fontWeight: FontWeight.w900, + const Icon(Icons.arrow_forward_ios_rounded, size: 15), + ], + ), + ), + ), + ); + } +} + +class _DangerActionTile extends StatelessWidget { + const _DangerActionTile({ + required this.title, + required this.subtitle, + required this.onTap, + }); + + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Material( + color: scheme.errorContainer.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 11, 12, 11), + child: Row( + children: [ + Icon( + Icons.delete_forever_outlined, + color: scheme.error, + size: 23, ), - ), - const SizedBox(height: 3), - Text( - subtitle, - maxLines: fullWidth ? 2 : 3, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: - destructive - ? scheme.onErrorContainer - : scheme.onSurfaceVariant, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + color: scheme.error, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onErrorContainer, + ), + ), + ], + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/src/widgets/seed_illustration.dart b/lib/src/widgets/seed_illustration.dart index 500d388..9387985 100644 --- a/lib/src/widgets/seed_illustration.dart +++ b/lib/src/widgets/seed_illustration.dart @@ -6,11 +6,13 @@ class SeedIllustration extends StatelessWidget { const SeedIllustration({ this.tone = SeedIllustrationTone.garden, this.size = 118, + this.hero = false, super.key, }); final SeedIllustrationTone tone; final double size; + final bool hero; @override Widget build(BuildContext context) { @@ -35,7 +37,7 @@ class SeedIllustration extends StatelessWidget { shape: BoxShape.circle, gradient: RadialGradient( colors: [ - softAccent, + softAccent.withValues(alpha: hero ? 0.92 : 1), scheme.surfaceContainerHigh.withValues(alpha: 0.28), Colors.transparent, ], @@ -43,53 +45,87 @@ class SeedIllustration extends StatelessWidget { ), ), ), + if (hero) + Positioned.fill( + child: CustomPaint( + painter: _OrbitPainter( + color: accent.withValues(alpha: 0.30), + quietColor: scheme.outlineVariant.withValues(alpha: 0.30), + ), + ), + ), + if (hero) + Positioned( + left: size * 0.08, + top: size * 0.08, + child: _Sparkle(size: size * 0.13, color: accent), + ), + if (hero) + Positioned( + right: size * 0.14, + bottom: size * 0.12, + child: _Sparkle(size: size * 0.10, color: scheme.tertiary), + ), Positioned( bottom: size * 0.16, child: Container( - width: size * 0.62, - height: size * 0.28, + width: size * (hero ? 0.72 : 0.62), + height: size * (hero ? 0.31 : 0.28), decoration: BoxDecoration( color: scheme.surfaceContainerHighest.withValues(alpha: 0.78), borderRadius: BorderRadius.circular(size), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.10), - blurRadius: 18, - offset: const Offset(0, 8), + color: Colors.black.withValues(alpha: hero ? 0.16 : 0.10), + blurRadius: hero ? 26 : 18, + offset: Offset(0, hero ? 12 : 8), ), ], ), ), ), Positioned( - top: size * 0.18, - left: size * 0.28, + top: size * (hero ? 0.15 : 0.18), + left: size * (hero ? 0.24 : 0.28), child: Transform.rotate( angle: -0.62, child: _LeafBlob( - width: size * 0.26, - height: size * 0.42, + width: size * (hero ? 0.30 : 0.26), + height: size * (hero ? 0.48 : 0.42), color: accent.withValues(alpha: 0.78), ), ), ), Positioned( - top: size * 0.16, - right: size * 0.28, + top: size * (hero ? 0.12 : 0.16), + right: size * (hero ? 0.24 : 0.28), child: Transform.rotate( angle: 0.62, child: _LeafBlob( - width: size * 0.28, - height: size * 0.45, + width: size * (hero ? 0.33 : 0.28), + height: size * (hero ? 0.52 : 0.45), color: scheme.primaryContainer.withValues(alpha: 0.86), ), ), ), + if (hero) + Positioned( + top: size * 0.25, + right: size * 0.36, + child: Transform.rotate( + angle: 0.10, + child: _LeafBlob( + width: size * 0.18, + height: size * 0.30, + color: scheme.tertiary.withValues(alpha: 0.62), + ), + ), + ), Positioned( top: size * 0.44, child: Container( - width: size * 0.08, - height: size * 0.28, + width: size * (hero ? 0.09 : 0.08), + height: size * (hero ? 0.32 : 0.28), decoration: BoxDecoration( color: scheme.primary.withValues(alpha: 0.78), borderRadius: BorderRadius.circular(99), @@ -101,8 +137,8 @@ class SeedIllustration extends StatelessWidget { child: Transform.rotate( angle: 0.18, child: Container( - width: size * 0.18, - height: size * 0.28, + width: size * (hero ? 0.21 : 0.18), + height: size * (hero ? 0.31 : 0.28), decoration: BoxDecoration( color: scheme.tertiaryContainer.withValues(alpha: 0.90), borderRadius: BorderRadius.circular(99), @@ -198,3 +234,81 @@ class _FloatingDot extends StatelessWidget { ); } } + +class _Sparkle extends StatelessWidget { + const _Sparkle({required this.size, required this.color}); + + final double size; + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: size, + child: CustomPaint(painter: _SparklePainter(color)), + ); + } +} + +class _SparklePainter extends CustomPainter { + const _SparklePainter(this.color); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = color.withValues(alpha: 0.72); + final center = Offset(size.width / 2, size.height / 2); + final path = + Path() + ..moveTo(center.dx, 0) + ..quadraticBezierTo( + center.dx * 1.18, + center.dy * 0.78, + size.width, + center.dy, + ) + ..quadraticBezierTo( + center.dx * 1.18, + center.dy * 1.18, + center.dx, + size.height, + ) + ..quadraticBezierTo(center.dx * 0.78, center.dy * 1.18, 0, center.dy) + ..quadraticBezierTo(center.dx * 0.78, center.dy * 0.78, center.dx, 0); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _SparklePainter oldDelegate) { + return oldDelegate.color != color; + } +} + +class _OrbitPainter extends CustomPainter { + const _OrbitPainter({required this.color, required this.quietColor}); + + final Color color; + final Color quietColor; + + @override + void paint(Canvas canvas, Size size) { + final rect = + Offset(size.width * 0.08, size.height * 0.12) & + Size(size.width * 0.84, size.height * 0.72); + final paint = + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = size.shortestSide * 0.018 + ..strokeCap = StrokeCap.round + ..color = quietColor; + canvas.drawArc(rect, -0.12, 4.8, false, paint); + paint.color = color; + canvas.drawArc(rect, 2.8, 1.25, false, paint); + } + + @override + bool shouldRepaint(covariant _OrbitPainter oldDelegate) { + return oldDelegate.color != color || oldDelegate.quietColor != quietColor; + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 7f7c021..79bacae 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -4,7 +4,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:focus_seed/src/app/focus_seed_app.dart'; import 'package:focus_seed/src/data/focus_seed_store.dart'; import 'package:focus_seed/src/data/local/focus_seed_database.dart'; -import 'package:focus_seed/src/models/language_preference.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { @@ -143,11 +142,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('\u8bed\u8a00'), findsOneWidget); - final languageSegments = find.byType(SegmentedButton); - final widget = tester.widget>( - languageSegments, - ); - widget.onSelectionChanged?.call({LanguagePreference.english}); + await tester.tap(find.byIcon(Icons.language_rounded)); await tester.pumpAndSettle(); expect(find.text('Language'), findsOneWidget);