checkpoint: focus seed 1.0.1 localized dev switch

This commit is contained in:
2026-06-11 23:35:49 +08:00
parent c51abdfb73
commit d06dc35ee6
72 changed files with 6155 additions and 1390 deletions
-52
View File
@@ -1,52 +0,0 @@
import 'package:flutter/material.dart';
class AppScaffold extends StatelessWidget {
const AppScaffold({
required this.title,
required this.subtitle,
required this.child,
this.action,
super.key,
});
final String title;
final String subtitle;
final Widget child;
final Widget? action;
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 3),
Text(
subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
if (action != null) action!,
],
),
),
Expanded(child: child),
],
);
}
}
+103 -63
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../core/json_utils.dart';
import '../l10n/l10n.dart';
import '../models/app_state.dart';
Future<void> showHabitDialog(
@@ -9,21 +10,26 @@ Future<void> showHabitDialog(
ValueChanged<String> onSubmit, {
String initialValue = '',
}) async {
final controller = TextEditingController(text: initialValue);
var value = initialValue;
await showDialog<void>(
context: context,
builder:
(context) => AlertDialog(
title: Text(initialValue.isEmpty ? 'Add habit' : 'Rename habit'),
content: TextField(
controller: controller,
title: Text(
initialValue.isEmpty
? context.l10n.addHabitTitle
: context.l10n.renameHabitTitle,
),
content: TextFormField(
initialValue: initialValue,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Example: Read 10 pages',
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: context.l10n.habitHint,
),
onSubmitted: (value) {
onChanged: (nextValue) => value = nextValue,
onFieldSubmitted: (value) {
onSubmit(value);
Navigator.pop(context);
},
@@ -31,19 +37,63 @@ Future<void> showHabitDialog(
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onSubmit(controller.text);
onSubmit(value);
Navigator.pop(context);
},
child: const Text('Save'),
child: Text(context.l10n.save),
),
],
),
);
}
Future<void> showIntentionDialog(
BuildContext context,
ValueChanged<String> onSubmit, {
String initialValue = '',
}) async {
var value = initialValue;
await showDialog<void>(
context: context,
builder:
(context) => AlertDialog(
title: Text(
initialValue.isEmpty
? context.l10n.setIntentionTitle
: context.l10n.editIntentionTitle,
),
content: TextFormField(
initialValue: initialValue,
autofocus: true,
maxLength: 90,
maxLines: 3,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(hintText: context.l10n.intentionHint),
onChanged: (nextValue) => value = nextValue,
onFieldSubmitted: (value) {
onSubmit(value);
Navigator.pop(context);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onSubmit(value);
Navigator.pop(context);
},
child: Text(context.l10n.save),
),
],
),
);
controller.dispose();
}
Future<void> confirmDeleteHabit(
@@ -54,21 +104,19 @@ Future<void> confirmDeleteHabit(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Delete habit?'),
content: const Text(
'Past completion records for this habit will be removed.',
),
title: Text(context.l10n.deleteHabitTitle),
content: Text(context.l10n.deleteHabitBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: const Text('Delete'),
child: Text(context.l10n.delete),
),
],
),
@@ -76,79 +124,78 @@ Future<void> confirmDeleteHabit(
}
Future<void> showExportDialog(BuildContext context, AppState state) async {
final controller = TextEditingController(text: prettyJson(state.toJson()));
final scaffoldContext = context;
final export = prettyJson(state.toJson());
await showDialog<void>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Export JSON'),
title: Text(context.l10n.exportJson),
content: SizedBox(
width: double.maxFinite,
child: TextField(
controller: controller,
minLines: 8,
maxLines: 12,
readOnly: true,
decoration: const InputDecoration(border: OutlineInputBorder()),
child: SingleChildScrollView(
child: SelectableText(
export,
style: const TextStyle(fontFamily: 'monospace'),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.close),
),
FilledButton.icon(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: controller.text));
final message = context.l10n.exportCopied;
await Clipboard.setData(ClipboardData(text: export));
if (context.mounted) {
Navigator.pop(context);
showSnack(context, 'Export copied');
showSnack(scaffoldContext, message);
}
},
icon: const Icon(Icons.copy),
label: const Text('Copy'),
label: Text(context.l10n.copy),
),
],
),
);
controller.dispose();
}
Future<void> showImportDialog(
BuildContext context,
ValueChanged<String> onImport,
) async {
final controller = TextEditingController();
var value = '';
await showDialog<void>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Import JSON'),
content: TextField(
controller: controller,
title: Text(context.l10n.importJson),
content: TextFormField(
minLines: 8,
maxLines: 12,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Paste Focus Seed JSON export',
onChanged: (nextValue) => value = nextValue,
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: context.l10n.importHint,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onImport(controller.text);
onImport(value);
Navigator.pop(context);
},
child: const Text('Import'),
child: Text(context.l10n.import),
),
],
),
);
controller.dispose();
}
Future<void> confirmClearData(
@@ -159,21 +206,19 @@ Future<void> confirmClearData(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Clear local data?'),
content: const Text(
'This resets habits, focus sessions, and reflections on this device.',
),
title: Text(context.l10n.clearDataTitle),
content: Text(context.l10n.clearDataBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: const Text('Clear'),
child: Text(context.l10n.clear),
),
],
),
@@ -189,23 +234,18 @@ void showPrivacySheet(BuildContext context) {
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: ListView(
shrinkWrap: true,
children: const [
children: [
Text(
'Privacy',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
),
SizedBox(height: 12),
Text(
'Focus Seed stores your anchor task, habits, focus sessions, '
'reflections, and settings locally on this device. V1 does not '
'include account sign-in, cloud sync, ads, analytics SDKs, or '
'billing SDKs.',
),
SizedBox(height: 12),
Text(
'You can export your local data as JSON, import a previous '
'export, or clear local data from Settings.',
context.l10n.privacyTitle,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
Text(context.l10n.privacyBodyOne),
const SizedBox(height: 12),
Text(context.l10n.privacyBodyTwo),
],
),
),
-37
View File
@@ -1,37 +0,0 @@
import 'package:flutter/material.dart';
import 'seed_card.dart';
class EmptyState extends StatelessWidget {
const EmptyState({
required this.icon,
required this.title,
required this.action,
required this.onAction,
super.key,
});
final IconData icon;
final String title;
final String action;
final VoidCallback onAction;
@override
Widget build(BuildContext context) {
return SeedCard(
child: Column(
children: [
Icon(icon, size: 42, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 12),
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onAction,
icon: const Icon(Icons.add),
label: Text(action),
),
],
),
);
}
}
+145
View File
@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
class FocusSeedHeader extends StatelessWidget {
const FocusSeedHeader({
required this.title,
required this.subtitle,
this.showBrand = true,
this.isSaving = false,
this.trailing,
super.key,
});
final String title;
final String subtitle;
final bool showBrand;
final bool isSaving;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showBrand) ...[
LayoutBuilder(
builder: (context, constraints) {
final textScale =
MediaQuery.textScalerOf(context).scale(14) / 14;
final compact = constraints.maxWidth < 340 || textScale > 1.3;
return Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(
alpha: 0.12,
),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.spa_rounded,
color: theme.colorScheme.primary,
size: 21,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.brandName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child:
isSaving
? _SaveStatus(
key: const ValueKey('saving'),
icon: Icons.sync_rounded,
label: l10n.saving,
compact: compact,
)
: _SaveStatus(
key: const ValueKey('saved'),
icon: Icons.smartphone_outlined,
label: l10n.local,
compact: compact,
),
),
if (trailing != null) ...[
const SizedBox(width: 4),
trailing!,
],
],
);
},
),
const SizedBox(height: 12),
],
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(fontSize: 24),
),
const SizedBox(height: 4),
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
class _SaveStatus extends StatelessWidget {
const _SaveStatus({
required this.icon,
required this.label,
required this.compact,
super.key,
});
final IconData icon;
final String label;
final bool compact;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Semantics(
label: label,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: theme.colorScheme.primary),
if (!compact) ...[
const SizedBox(width: 4),
Text(label, style: theme.textTheme.labelSmall),
],
],
),
),
);
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import '../app/app_theme.dart';
import '../l10n/l10n.dart';
import '../models/growth_stage.dart';
class GrowthObject extends StatelessWidget {
const GrowthObject({
required this.stage,
this.size = 148,
this.showAmbientHalo = true,
super.key,
});
final GrowthStage stage;
final double size;
final bool showAmbientHalo;
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.of(context).disableAnimations;
final visuals = FocusSeedVisuals.of(context);
final image = Image.asset(
stage.assetPath,
key: ValueKey(stage),
width: size,
height: size,
fit: BoxFit.contain,
gaplessPlayback: true,
semanticLabel: localizedGrowthSemantic(context.l10n, stage),
);
return SizedBox.square(
dimension: size,
child: Stack(
alignment: Alignment.center,
children: [
if (showAmbientHalo)
Container(
width: size * 0.76,
height: size * 0.76,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: visuals.ambientTint,
),
),
AnimatedSwitcher(
duration:
reduceMotion
? Duration.zero
: const Duration(milliseconds: 420),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
if (reduceMotion) {
return FadeTransition(opacity: animation, child: child);
}
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween(begin: 0.94, end: 1.0).animate(animation),
child: child,
),
);
},
child: image,
),
],
),
);
}
}
-77
View File
@@ -1,77 +0,0 @@
import 'package:flutter/material.dart';
import '../models/habit.dart';
import 'seed_card.dart';
class HabitRow extends StatelessWidget {
const HabitRow({
required this.habit,
required this.dateKey,
required this.onToggle,
this.onEdit,
this.onDelete,
super.key,
});
final Habit habit;
final String dateKey;
final VoidCallback onToggle;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final done = habit.completedDates.contains(dateKey);
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.only(bottom: 10),
child: SeedCard(
child: Row(
children: [
IconButton.filledTonal(
tooltip: done ? 'Mark not done' : 'Mark done',
onPressed: onToggle,
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 160),
child: Icon(
done ? Icons.check : Icons.circle_outlined,
key: ValueKey(done),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
habit.title,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
decoration: done ? TextDecoration.lineThrough : null,
color:
done
? Theme.of(context).colorScheme.onSurfaceVariant
: null,
),
),
),
if (onEdit != null)
PopupMenuButton<String>(
tooltip: 'Habit options',
onSelected: (value) {
if (value == 'edit') {
onEdit?.call();
}
if (value == 'delete') {
onDelete?.call();
}
},
itemBuilder:
(context) => const [
PopupMenuItem(value: 'edit', child: Text('Rename')),
PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
],
),
),
);
}
}
-60
View File
@@ -1,60 +0,0 @@
import 'package:flutter/material.dart';
class InlineEditableText extends StatefulWidget {
const InlineEditableText({
required this.value,
required this.emptyText,
required this.onSubmitted,
super.key,
});
final String value;
final String emptyText;
final ValueChanged<String> onSubmitted;
@override
State<InlineEditableText> createState() => _InlineEditableTextState();
}
class _InlineEditableTextState extends State<InlineEditableText> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
}
@override
void didUpdateWidget(covariant InlineEditableText oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value && _controller.text != widget.value) {
_controller.text = widget.value;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
textCapitalization: TextCapitalization.sentences,
onSubmitted: widget.onSubmitted,
onTapOutside: (_) => widget.onSubmitted(_controller.text),
decoration: InputDecoration(
hintText: widget.emptyText,
suffixIcon: IconButton(
tooltip: 'Save anchor',
onPressed: () => widget.onSubmitted(_controller.text),
icon: const Icon(Icons.check),
),
border: const OutlineInputBorder(),
),
);
}
}
-61
View File
@@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'seed_card.dart';
class MetricTile extends StatelessWidget {
const MetricTile({
required this.label,
required this.value,
required this.suffix,
required this.icon,
this.accent,
super.key,
});
final String label;
final String value;
final String suffix;
final IconData icon;
final Color? accent;
@override
Widget build(BuildContext context) {
final color = accent ?? Theme.of(context).colorScheme.primary;
return SeedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: color),
const SizedBox(height: 12),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
if (suffix.isNotEmpty) ...[
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Text(
suffix,
style: Theme.of(context).textTheme.labelMedium,
),
),
],
],
),
),
const SizedBox(height: 4),
Text(label, style: Theme.of(context).textTheme.labelMedium),
],
),
);
}
}
-25
View File
@@ -1,25 +0,0 @@
import 'package:flutter/material.dart';
class SectionLabel extends StatelessWidget {
const SectionLabel({required this.icon, required this.text, super.key});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Text(
text,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
],
);
}
}
-16
View File
@@ -1,16 +0,0 @@
import 'package:flutter/material.dart';
class SeedCard extends StatelessWidget {
const SeedCard({required this.child, this.margin, super.key});
final Widget child;
final EdgeInsetsGeometry? margin;
@override
Widget build(BuildContext context) {
return Card(
margin: margin,
child: Padding(padding: const EdgeInsets.all(16), child: child),
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import '../app/app_theme.dart';
class SurfacePanel extends StatelessWidget {
const SurfacePanel({
required this.child,
this.padding = const EdgeInsets.all(16),
this.margin,
this.color,
this.borderColor,
this.elevated = false,
this.onTap,
super.key,
});
final Widget child;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? margin;
final Color? color;
final Color? borderColor;
final bool elevated;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visuals = FocusSeedVisuals.of(context);
final decoration = BoxDecoration(
color: color ?? theme.colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: borderColor ?? theme.colorScheme.outlineVariant,
),
boxShadow:
elevated
? [
BoxShadow(
color: visuals.shadow,
blurRadius: 16,
offset: const Offset(0, 5),
),
]
: null,
);
final content = Padding(padding: padding, child: child);
return Container(
margin: margin,
decoration: decoration,
clipBehavior: Clip.antiAlias,
child:
onTap == null
? content
: Material(
color: Colors.transparent,
child: InkWell(onTap: onTap, child: content),
),
);
}
}
-77
View File
@@ -1,77 +0,0 @@
import 'package:flutter/material.dart';
import '../core/date_keys.dart';
import '../models/app_state.dart';
import 'section_label.dart';
import 'seed_card.dart';
class WeeklyProgress extends StatelessWidget {
const WeeklyProgress({required this.state, super.key});
final AppState state;
@override
Widget build(BuildContext context) {
final days = lastSevenDateKeys();
return SeedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionLabel(
icon: Icons.insights_outlined,
text: '7-day pulse',
),
const SizedBox(height: 14),
Row(
children:
days.map((day) {
final minutes = minutesForDate(state.sessions, day);
final habits =
state.habits
.where((habit) => habit.completedDates.contains(day))
.length;
final hasReflection = state.reflections.any(
(reflection) => reflection.dateKey == day,
);
final score =
(minutes > 0 ? 1 : 0) +
(habits > 0 ? 1 : 0) +
(hasReflection ? 1 : 0);
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Column(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
height: 48 + (score * 8),
decoration: BoxDecoration(
color:
score == 0
? Theme.of(
context,
).colorScheme.surfaceContainerHighest
: Theme.of(
context,
).colorScheme.primary.withValues(
alpha: 0.35 + score * 0.15,
),
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(height: 6),
Text(
day.substring(5),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
);
}).toList(),
),
],
),
);
}
}