release: seal Focus Seed 1.0.0

This commit is contained in:
2026-06-11 00:10:50 +08:00
commit 67ecb15c03
121 changed files with 5253 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
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),
],
);
}
}
+217
View File
@@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../core/json_utils.dart';
import '../models/app_state.dart';
Future<void> showHabitDialog(
BuildContext context,
ValueChanged<String> onSubmit, {
String initialValue = '',
}) async {
final controller = TextEditingController(text: initialValue);
await showDialog<void>(
context: context,
builder:
(context) => AlertDialog(
title: Text(initialValue.isEmpty ? 'Add habit' : 'Rename habit'),
content: TextField(
controller: controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Example: Read 10 pages',
),
onSubmitted: (value) {
onSubmit(value);
Navigator.pop(context);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
onSubmit(controller.text);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
);
controller.dispose();
}
Future<void> confirmDeleteHabit(
BuildContext context,
VoidCallback onConfirm,
) async {
await showDialog<void>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Delete habit?'),
content: const Text(
'Past completion records for this habit will be removed.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: const Text('Delete'),
),
],
),
);
}
Future<void> showExportDialog(BuildContext context, AppState state) async {
final controller = TextEditingController(text: prettyJson(state.toJson()));
await showDialog<void>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Export JSON'),
content: SizedBox(
width: double.maxFinite,
child: TextField(
controller: controller,
minLines: 8,
maxLines: 12,
readOnly: true,
decoration: const InputDecoration(border: OutlineInputBorder()),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
FilledButton.icon(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: controller.text));
if (context.mounted) {
Navigator.pop(context);
showSnack(context, 'Export copied');
}
},
icon: const Icon(Icons.copy),
label: const Text('Copy'),
),
],
),
);
controller.dispose();
}
Future<void> showImportDialog(
BuildContext context,
ValueChanged<String> onImport,
) async {
final controller = TextEditingController();
await showDialog<void>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Import JSON'),
content: TextField(
controller: controller,
minLines: 8,
maxLines: 12,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Paste Focus Seed JSON export',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
onImport(controller.text);
Navigator.pop(context);
},
child: const Text('Import'),
),
],
),
);
controller.dispose();
}
Future<void> confirmClearData(
BuildContext context,
VoidCallback onConfirm,
) async {
await showDialog<void>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Clear local data?'),
content: const Text(
'This resets habits, focus sessions, and reflections on this device.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: const Text('Clear'),
),
],
),
);
}
void showPrivacySheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder:
(context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: ListView(
shrinkWrap: true,
children: const [
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.',
),
],
),
),
);
}
void showSnack(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
+37
View File
@@ -0,0 +1,37 @@
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),
),
],
),
);
}
}
+77
View File
@@ -0,0 +1,77 @@
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
@@ -0,0 +1,60 @@
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(),
),
);
}
}
+10
View File
@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class LoadingView extends StatelessWidget {
const LoadingView({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: CircularProgressIndicator());
}
}
+61
View File
@@ -0,0 +1,61 @@
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
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,16 @@
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),
);
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
class SettingsTile extends StatelessWidget {
const SettingsTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
this.destructive = false,
super.key,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
final bool destructive;
@override
Widget build(BuildContext context) {
final color = destructive ? Theme.of(context).colorScheme.error : null;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(icon, color: color),
title: Text(title, style: TextStyle(color: color)),
subtitle: Text(subtitle),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: onTap,
);
}
}
+77
View File
@@ -0,0 +1,77 @@
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(),
),
],
),
);
}
}