feat: enrich product visual depth
This commit is contained in:
@@ -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<HistoryScreen> {
|
||||
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),
|
||||
_HistorySwitch(
|
||||
value: _view,
|
||||
onChanged: (value) => setState(() => _view = value),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _HistoryView.notes,
|
||||
icon: const Icon(Icons.edit_note_rounded),
|
||||
label: Text(l10n.notesHistory),
|
||||
),
|
||||
],
|
||||
selected: {_view},
|
||||
onSelectionChanged:
|
||||
(selection) => setState(() => _view = selection.first),
|
||||
),
|
||||
),
|
||||
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<HistoryScreen> {
|
||||
? _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<HistoryScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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<FocusSession> 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(
|
||||
return 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),
|
||||
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<ReflectionEntry> reflections;
|
||||
final VoidCallback onStartToday;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -135,65 +244,166 @@ class _ReflectionHistoryList extends StatelessWidget {
|
||||
return _EmptyHistory(
|
||||
title: l10n.emptyNotesHistoryTitle,
|
||||
body: l10n.emptyNotesHistoryBody,
|
||||
tone: SeedIllustrationTone.data,
|
||||
onStartToday: onStartToday,
|
||||
);
|
||||
}
|
||||
return SurfacePanel(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||||
child: Column(
|
||||
return Column(
|
||||
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,
|
||||
),
|
||||
subtitle: Text(reflections[index].dateKey),
|
||||
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});
|
||||
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);
|
||||
return SurfacePanel(
|
||||
padding: const EdgeInsets.all(22),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_graph_rounded,
|
||||
size: 38,
|
||||
color: theme.colorScheme.primary,
|
||||
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,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
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.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
const SizedBox(height: 8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 310),
|
||||
child: Text(
|
||||
body,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
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.fromLTRB(13, 12, 14, 12),
|
||||
borderColor: scheme.outlineVariant.withValues(alpha: 0.60),
|
||||
child: Row(
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+450
-216
@@ -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<AppearancePreference>(
|
||||
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<LanguagePreference>(
|
||||
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);
|
||||
}
|
||||
}),
|
||||
_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,24 +92,37 @@ 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: Stack(
|
||||
children: [
|
||||
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: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -220,11 +130,11 @@ class _PersonalGardenCard extends StatelessWidget {
|
||||
l10n.personalGardenTitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
l10n.personalGardenSubtitle,
|
||||
maxLines: 2,
|
||||
@@ -236,41 +146,31 @@ class _PersonalGardenCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const SeedIllustration(
|
||||
size: 88,
|
||||
tone: SeedIllustrationTone.data,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
const SizedBox(height: 28),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _DataMetric(
|
||||
_MetricPill(
|
||||
icon: Icons.timer_outlined,
|
||||
label: l10n.focusDataLabel,
|
||||
value: focusSessions,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _DataMetric(
|
||||
_MetricPill(
|
||||
icon: Icons.checklist_rounded,
|
||||
label: l10n.habitsDataLabel,
|
||||
value: habits,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _DataMetric(
|
||||
_MetricPill(
|
||||
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,21 +193,28 @@ 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: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: scheme.primary, size: 18),
|
||||
Icon(icon, color: scheme.primary, size: 17),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: value.toDouble()),
|
||||
duration: const Duration(milliseconds: 260),
|
||||
@@ -315,16 +222,288 @@ class _DataMetric extends StatelessWidget {
|
||||
builder:
|
||||
(context, animatedValue, _) => Text(
|
||||
animatedValue.round().toString(),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
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<AppearancePreference> onAppearanceChanged;
|
||||
final LanguagePreference language;
|
||||
final bool showLanguageTools;
|
||||
final ValueChanged<LanguagePreference> 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<AppearancePreference>(
|
||||
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<LanguagePreference>(
|
||||
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 _PreferenceChoice<T> {
|
||||
const _PreferenceChoice({
|
||||
required this.value,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
final T value;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
}
|
||||
|
||||
class _PreferenceSwitcher<T> extends StatelessWidget {
|
||||
const _PreferenceSwitcher({
|
||||
required this.value,
|
||||
required this.choices,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final T value;
|
||||
final List<_PreferenceChoice<T>> choices;
|
||||
final ValueChanged<T> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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.labelSmall?.copyWith(
|
||||
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,
|
||||
),
|
||||
),
|
||||
@@ -333,113 +512,168 @@ class _DataMetric extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
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 _ActionGrid extends StatelessWidget {
|
||||
const _ActionGrid({required this.children});
|
||||
|
||||
final List<Widget> children;
|
||||
|
||||
@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],
|
||||
}),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
return GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisSpacing: 10,
|
||||
childAspectRatio: 1.22,
|
||||
children: children,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionCard extends StatelessWidget {
|
||||
const _ActionCard({
|
||||
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(
|
||||
return Material(
|
||||
color: scheme.surfaceContainerLow.withValues(alpha: 0.72),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
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,
|
||||
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: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
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 SizedBox(height: 3),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: fullWidth ? 2 : 3,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
destructive
|
||||
? scheme.onErrorContainer
|
||||
: scheme.onSurfaceVariant,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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(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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LanguagePreference>);
|
||||
final widget = tester.widget<SegmentedButton<LanguagePreference>>(
|
||||
languageSegments,
|
||||
);
|
||||
widget.onSelectionChanged?.call({LanguagePreference.english});
|
||||
await tester.tap(find.byIcon(Icons.language_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Language'), findsOneWidget);
|
||||
|
||||
Reference in New Issue
Block a user