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 AppTheme {
static const seed = Color(0xFF2E7D6B);
static const accent = Color(0xFFF4B740);
static const paper = Color(0xFFF7F8F5);
static ThemeData light() {
final scheme = ColorScheme.fromSeed(seedColor: seed, surface: paper);
return _base(scheme);
}
static ThemeData dark() {
final scheme = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.dark,
);
return _base(scheme);
}
static ThemeData _base(ColorScheme scheme) {
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
navigationBarTheme: NavigationBarThemeData(
indicatorColor: scheme.primaryContainer,
labelTextStyle: WidgetStateProperty.resolveWith(
(_) => const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
),
cardTheme: CardTheme(
elevation: 0,
margin: EdgeInsets.zero,
color: scheme.surfaceContainerHighest.withValues(alpha: 0.55),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(48, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(48, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
);
}
}
+19
View File
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import '../features/shell/focus_seed_home.dart';
import 'app_theme.dart';
class FocusSeedApp extends StatelessWidget {
const FocusSeedApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Focus Seed',
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
home: const FocusSeedHome(),
);
}
}
+76
View File
@@ -0,0 +1,76 @@
import '../models/focus_session.dart';
import '../models/habit.dart';
import '../models/reflection_entry.dart';
String todayKey() => dateKey(DateTime.now());
String dateKey(DateTime date) {
final local = DateTime(date.year, date.month, date.day);
return '${local.year.toString().padLeft(4, '0')}-'
'${local.month.toString().padLeft(2, '0')}-'
'${local.day.toString().padLeft(2, '0')}';
}
String readableToday() {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
final now = DateTime.now();
return '${months[now.month - 1]} ${now.day}, ${now.year}';
}
List<String> lastSevenDateKeys() {
final now = DateTime.now();
return List.generate(
7,
(index) => dateKey(now.subtract(Duration(days: 6 - index))),
);
}
int minutesForDate(List<FocusSession> sessions, String key) {
return sessions
.where((session) => dateKey(session.completedAt) == key)
.fold<int>(0, (total, session) => total + session.minutes);
}
int currentStreak(
List<Habit> habits,
List<FocusSession> sessions,
List<ReflectionEntry> reflections,
) {
var streak = 0;
var cursor = DateTime.now();
while (true) {
final key = dateKey(cursor);
final hasHabit = habits.any((habit) => habit.completedDates.contains(key));
final hasSession = sessions.any(
(session) => dateKey(session.completedAt) == key,
);
final hasReflection = reflections.any(
(reflection) => reflection.dateKey == key,
);
if (!hasHabit && !hasSession && !hasReflection) {
return streak;
}
streak += 1;
cursor = cursor.subtract(const Duration(days: 1));
}
}
String formatSeconds(int seconds) {
final minutes = seconds ~/ 60;
final remaining = seconds % 60;
return '${minutes.toString().padLeft(2, '0')}:'
'${remaining.toString().padLeft(2, '0')}';
}
+9
View File
@@ -0,0 +1,9 @@
extension FirstOrNull<T> on Iterable<T> {
T? get firstOrNull {
final iterator = this.iterator;
if (iterator.moveNext()) {
return iterator.current;
}
return null;
}
}
+5
View File
@@ -0,0 +1,5 @@
import 'dart:convert';
String prettyJson(Map<String, dynamic> value) {
return const JsonEncoder.withIndent(' ').convert(value);
}
+33
View File
@@ -0,0 +1,33 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../core/json_utils.dart';
import '../models/app_state.dart';
class FocusSeedStore {
static const _storageKey = 'focus_seed_state_v1';
Future<AppState> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey);
if (raw == null || raw.isEmpty) {
return AppState.starter();
}
try {
return AppState.fromJson(jsonDecode(raw) as Map<String, dynamic>);
} on Object {
return AppState.starter();
}
}
Future<void> save(AppState state) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_storageKey, prettyJson(state.toJson()));
}
Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_storageKey);
}
}
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import '../../core/date_keys.dart';
import '../../models/app_state.dart';
import '../../models/habit.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/dialogs.dart';
import '../../widgets/empty_state.dart';
import '../../widgets/habit_row.dart';
class HabitsScreen extends StatelessWidget {
const HabitsScreen({
required this.state,
required this.onAddHabit,
required this.onRenameHabit,
required this.onDeleteHabit,
required this.onToggleHabit,
super.key,
});
final AppState state;
final ValueChanged<String> onAddHabit;
final void Function(Habit habit, String title) onRenameHabit;
final ValueChanged<Habit> onDeleteHabit;
final ValueChanged<Habit> onToggleHabit;
@override
Widget build(BuildContext context) {
final today = todayKey();
return AppScaffold(
title: 'Habits',
subtitle: 'Keep the list small and repeatable.',
action: IconButton.filledTonal(
tooltip: 'Add habit',
onPressed: () => showHabitDialog(context, onAddHabit),
icon: const Icon(Icons.add),
),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: [
if (state.habits.isEmpty)
EmptyState(
icon: Icons.checklist_outlined,
title: 'No habits yet',
action: 'Add habit',
onAction: () => showHabitDialog(context, onAddHabit),
)
else
...state.habits.map(
(habit) => HabitRow(
habit: habit,
dateKey: today,
onToggle: () => onToggleHabit(habit),
onEdit:
() => showHabitDialog(
context,
(title) => onRenameHabit(habit, title),
initialValue: habit.title,
),
onDelete:
() =>
confirmDeleteHabit(context, () => onDeleteHabit(habit)),
),
),
],
),
);
}
}
+147
View File
@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import '../../app/app_theme.dart';
import '../../core/date_keys.dart';
import '../../models/app_state.dart';
import '../../models/habit.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/dialogs.dart';
import '../../widgets/empty_state.dart';
import '../../widgets/habit_row.dart';
import '../../widgets/inline_editable_text.dart';
import '../../widgets/metric_tile.dart';
import '../../widgets/section_label.dart';
import '../../widgets/seed_card.dart';
import '../../widgets/weekly_progress.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({
required this.state,
required this.onAnchorChanged,
required this.onOpenTimer,
required this.onToggleHabit,
required this.onAddHabit,
super.key,
});
final AppState state;
final ValueChanged<String> onAnchorChanged;
final VoidCallback onOpenTimer;
final ValueChanged<Habit> onToggleHabit;
final ValueChanged<String> onAddHabit;
@override
Widget build(BuildContext context) {
final today = todayKey();
final completed =
state.habits
.where((habit) => habit.completedDates.contains(today))
.length;
final focusMinutes = minutesForDate(state.sessions, today);
final streak = currentStreak(
state.habits,
state.sessions,
state.reflections,
);
return AppScaffold(
title: 'Focus Seed',
subtitle: readableToday(),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: [
SeedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionLabel(
icon: Icons.flag_outlined,
text: 'Today anchor',
),
const SizedBox(height: 12),
InlineEditableText(
value: state.anchor,
emptyText: 'Choose one important task',
onSubmitted: onAnchorChanged,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onOpenTimer,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Start focus'),
),
],
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: MetricTile(
label: 'Focus',
value: '$focusMinutes',
suffix: 'min',
icon: Icons.timer_outlined,
),
),
const SizedBox(width: 10),
Expanded(
child: MetricTile(
label: 'Habits',
value: '$completed/${state.habits.length}',
suffix: '',
icon: Icons.check_circle_outline,
),
),
const SizedBox(width: 10),
Expanded(
child: MetricTile(
label: 'Streak',
value: '$streak',
suffix: 'days',
icon: Icons.local_fire_department_outlined,
accent: AppTheme.accent,
),
),
],
),
const SizedBox(height: 18),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Today habits',
style: Theme.of(context).textTheme.titleMedium,
),
IconButton.filledTonal(
tooltip: 'Add habit',
onPressed: () => showHabitDialog(context, onAddHabit),
icon: const Icon(Icons.add),
),
],
),
const SizedBox(height: 8),
if (state.habits.isEmpty)
EmptyState(
icon: Icons.checklist_rtl,
title: 'Add one tiny habit',
action: 'Add habit',
onAction: () => showHabitDialog(context, onAddHabit),
)
else
...state.habits
.take(5)
.map(
(habit) => HabitRow(
habit: habit,
dateKey: today,
onToggle: () => onToggleHabit(habit),
),
),
const SizedBox(height: 18),
WeeklyProgress(state: state),
],
),
);
}
}
@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import '../../core/date_keys.dart';
import '../../core/iterable_extensions.dart';
import '../../models/app_state.dart';
import '../../models/mood.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/section_label.dart';
import '../../widgets/seed_card.dart';
class ReflectionScreen extends StatefulWidget {
const ReflectionScreen({
required this.state,
required this.onSave,
super.key,
});
final AppState state;
final void Function(String note, Mood mood) onSave;
@override
State<ReflectionScreen> createState() => _ReflectionScreenState();
}
class _ReflectionScreenState extends State<ReflectionScreen> {
final _controller = TextEditingController();
Mood _mood = Mood.clear;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final today = todayKey();
final todayReflection =
widget.state.reflections
.where((item) => item.dateKey == today)
.firstOrNull;
if (todayReflection != null && _controller.text.isEmpty) {
_controller.text = todayReflection.note;
_mood = todayReflection.mood;
}
return AppScaffold(
title: 'Reflection',
subtitle: 'One sentence is enough.',
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: [
SeedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionLabel(
icon: Icons.psychology_outlined,
text: 'How did it feel?',
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children:
Mood.values
.map(
(mood) => ChoiceChip(
avatar: Icon(mood.icon, size: 18),
label: Text(mood.label),
selected: _mood == mood,
onSelected: (_) => setState(() => _mood = mood),
),
)
.toList(),
),
const SizedBox(height: 14),
TextField(
controller: _controller,
minLines: 4,
maxLines: 7,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'What made today a little better?',
),
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: () => widget.onSave(_controller.text, _mood),
icon: const Icon(Icons.save_outlined),
label: const Text('Save reflection'),
),
],
),
),
const SizedBox(height: 18),
Text('Recent notes', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (widget.state.reflections.isEmpty)
const SeedCard(
child: Text(
'Your reflections will appear here after you save one.',
),
)
else
...widget.state.reflections.reversed
.take(7)
.map(
(entry) => SeedCard(
margin: const EdgeInsets.only(bottom: 10),
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(entry.mood.icon),
title: Text(entry.note),
subtitle: Text(entry.dateKey),
),
),
),
],
),
);
}
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import '../../models/app_state.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/dialogs.dart';
import '../../widgets/seed_card.dart';
import '../../widgets/settings_tile.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({
required this.state,
required this.onImport,
required this.onClear,
super.key,
});
final AppState state;
final ValueChanged<String> onImport;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return AppScaffold(
title: 'Settings',
subtitle: 'Private by default. No account required.',
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: [
SeedCard(
child: Column(
children: [
SettingsTile(
icon: Icons.ios_share,
title: 'Export JSON',
subtitle: 'Copy your local data as readable JSON.',
onTap: () => showExportDialog(context, state),
),
const Divider(),
SettingsTile(
icon: Icons.file_upload_outlined,
title: 'Import JSON',
subtitle: 'Paste a previous Focus Seed export.',
onTap: () => showImportDialog(context, onImport),
),
const Divider(),
SettingsTile(
icon: Icons.privacy_tip_outlined,
title: 'Privacy',
subtitle:
'Your data stays on this device unless you export it.',
onTap: () => showPrivacySheet(context),
),
],
),
),
const SizedBox(height: 14),
SeedCard(
child: SettingsTile(
icon: Icons.delete_outline,
title: 'Clear local data',
subtitle: 'Reset habits, sessions, and reflections.',
destructive: true,
onTap: () => confirmClearData(context, onClear),
),
),
],
),
);
}
}
+269
View File
@@ -0,0 +1,269 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/date_keys.dart';
import '../../data/focus_seed_store.dart';
import '../../models/app_state.dart';
import '../../models/focus_session.dart';
import '../../models/habit.dart';
import '../../models/mood.dart';
import '../../models/reflection_entry.dart';
import '../../services/analytics_provider.dart';
import '../../widgets/dialogs.dart';
import '../../widgets/loading_view.dart';
import '../habits/habits_screen.dart';
import '../home/home_screen.dart';
import '../reflection/reflection_screen.dart';
import '../settings/settings_screen.dart';
import '../timer/timer_screen.dart';
class FocusSeedHome extends StatefulWidget {
const FocusSeedHome({super.key});
@override
State<FocusSeedHome> createState() => _FocusSeedHomeState();
}
class _FocusSeedHomeState extends State<FocusSeedHome> {
final _store = FocusSeedStore();
final _analytics = const NoopAnalyticsProvider();
AppState? _state;
int _tabIndex = 0;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final state = await _store.load();
if (!mounted) {
return;
}
setState(() => _state = state);
}
Future<void> _setStateAndSave(AppState state) async {
setState(() => _state = state);
await _store.save(state);
}
@override
Widget build(BuildContext context) {
final state = _state;
return Scaffold(
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child:
state == null
? const LoadingView()
: SafeArea(
child: IndexedStack(
index: _tabIndex,
children: [
HomeScreen(
state: state,
onAnchorChanged:
(anchor) => _setStateAndSave(
state.copyWith(anchor: anchor.trim()),
),
onOpenTimer: () => setState(() => _tabIndex = 1),
onToggleHabit: _toggleHabit,
onAddHabit: _addHabit,
),
TimerScreen(
onComplete: _completeFocusSession,
onReflect: () => setState(() => _tabIndex = 3),
),
HabitsScreen(
state: state,
onAddHabit: _addHabit,
onRenameHabit: _renameHabit,
onDeleteHabit: _deleteHabit,
onToggleHabit: _toggleHabit,
),
ReflectionScreen(state: state, onSave: _saveReflection),
SettingsScreen(
state: state,
onImport: _importState,
onClear: _clearData,
),
],
),
),
),
bottomNavigationBar:
state == null
? null
: NavigationBar(
selectedIndex: _tabIndex,
onDestinationSelected: (index) {
HapticFeedback.selectionClick();
setState(() => _tabIndex = index);
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Today',
),
NavigationDestination(
icon: Icon(Icons.timer_outlined),
selectedIcon: Icon(Icons.timer),
label: 'Timer',
),
NavigationDestination(
icon: Icon(Icons.check_circle_outline),
selectedIcon: Icon(Icons.check_circle),
label: 'Habits',
),
NavigationDestination(
icon: Icon(Icons.edit_note_outlined),
selectedIcon: Icon(Icons.edit_note),
label: 'Reflect',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
Future<void> _toggleHabit(Habit habit) async {
final state = _state;
if (state == null) {
return;
}
final today = todayKey();
final completedDates = Set<String>.from(habit.completedDates);
if (completedDates.contains(today)) {
completedDates.remove(today);
} else {
completedDates.add(today);
await HapticFeedback.lightImpact();
}
final habits =
state.habits
.map(
(item) =>
item.id == habit.id
? habit.copyWith(completedDates: completedDates)
: item,
)
.toList();
_analytics.track('habit_toggled');
await _setStateAndSave(state.copyWith(habits: habits));
}
Future<void> _addHabit(String title) async {
final state = _state;
final clean = title.trim();
if (state == null || clean.isEmpty) {
return;
}
final habit = Habit(
id: 'habit-${DateTime.now().microsecondsSinceEpoch}',
title: clean,
createdAt: DateTime.now(),
completedDates: {},
);
_analytics.track('habit_created');
await _setStateAndSave(state.copyWith(habits: [...state.habits, habit]));
}
Future<void> _renameHabit(Habit habit, String title) async {
final state = _state;
final clean = title.trim();
if (state == null || clean.isEmpty) {
return;
}
final habits =
state.habits
.map(
(item) =>
item.id == habit.id ? item.copyWith(title: clean) : item,
)
.toList();
await _setStateAndSave(state.copyWith(habits: habits));
}
Future<void> _deleteHabit(Habit habit) async {
final state = _state;
if (state == null) {
return;
}
final habits = state.habits.where((item) => item.id != habit.id).toList();
await _setStateAndSave(state.copyWith(habits: habits));
}
Future<void> _completeFocusSession(int minutes) async {
final state = _state;
if (state == null) {
return;
}
final session = FocusSession(
id: 'session-${DateTime.now().microsecondsSinceEpoch}',
minutes: minutes,
completedAt: DateTime.now(),
);
_analytics.track('focus_completed', {'minutes': minutes});
await _setStateAndSave(
state.copyWith(sessions: [...state.sessions, session]),
);
}
Future<void> _saveReflection(String note, Mood mood) async {
final state = _state;
final clean = note.trim();
if (state == null || clean.isEmpty) {
return;
}
final today = todayKey();
final entry = ReflectionEntry(
id: 'reflection-${DateTime.now().microsecondsSinceEpoch}',
dateKey: today,
note: clean,
mood: mood,
createdAt: DateTime.now(),
);
final reflections = [
...state.reflections.where((item) => item.dateKey != today),
entry,
];
_analytics.track('reflection_saved');
await _setStateAndSave(state.copyWith(reflections: reflections));
if (mounted) {
showSnack(context, 'Reflection saved');
}
}
Future<void> _importState(String raw) async {
try {
final imported = AppState.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
await _setStateAndSave(imported);
if (mounted) {
showSnack(context, 'Data imported');
}
} on Object {
if (mounted) {
showSnack(context, 'Import failed. Check the JSON and try again.');
}
}
}
Future<void> _clearData() async {
await _store.clear();
await _setStateAndSave(AppState.starter());
if (mounted) {
showSnack(context, 'Local data cleared');
}
}
}
+212
View File
@@ -0,0 +1,212 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/date_keys.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/seed_card.dart';
class TimerScreen extends StatefulWidget {
const TimerScreen({
required this.onComplete,
required this.onReflect,
super.key,
});
final ValueChanged<int> onComplete;
final VoidCallback onReflect;
@override
State<TimerScreen> createState() => _TimerScreenState();
}
class _TimerScreenState extends State<TimerScreen> {
static const _presets = [15, 25, 45];
Timer? _timer;
int _selectedMinutes = 25;
int _remainingSeconds = 25 * 60;
bool _running = false;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final totalSeconds = _selectedMinutes * 60;
final progress = 1 - (_remainingSeconds / totalSeconds);
return AppScaffold(
title: 'Focus timer',
subtitle: 'No ads or distractions here.',
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
children: [
SeedCard(
child: Column(
children: [
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
children:
_presets
.map(
(minutes) => ChoiceChip(
label: Text('$minutes min'),
selected: _selectedMinutes == minutes,
onSelected:
_running
? null
: (_) => setState(() {
_selectedMinutes = minutes;
_remainingSeconds = minutes * 60;
}),
),
)
.toList(),
),
const SizedBox(height: 30),
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: progress.clamp(0, 1)),
duration: const Duration(milliseconds: 240),
builder:
(context, value, child) => SizedBox.square(
dimension: 220,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: value,
strokeWidth: 14,
strokeCap: StrokeCap.round,
backgroundColor:
Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
child!,
],
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
formatSeconds(_remainingSeconds),
style: Theme.of(context).textTheme.displayMedium
?.copyWith(fontWeight: FontWeight.w800),
),
Text(
_running ? 'Focus in progress' : 'Ready when you are',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
const SizedBox(height: 28),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _running ? _pause : _start,
icon: Icon(_running ? Icons.pause : Icons.play_arrow),
label: Text(_running ? 'Pause' : 'Start'),
),
),
const SizedBox(width: 10),
IconButton.filledTonal(
tooltip: 'Reset timer',
onPressed: _reset,
icon: const Icon(Icons.restart_alt),
),
],
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _completeNow,
icon: const Icon(Icons.check),
label: const Text('Mark session complete'),
),
],
),
),
const SizedBox(height: 16),
SeedCard(
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.edit_note_outlined),
title: const Text('Finish with a one-minute reflection'),
subtitle: const Text('Capture what worked while it is fresh.'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: widget.onReflect,
),
),
],
),
);
}
void _start() {
if (_running) {
return;
}
HapticFeedback.selectionClick();
setState(() => _running = true);
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (_remainingSeconds <= 1) {
_completeNow();
return;
}
setState(() => _remainingSeconds -= 1);
});
}
void _pause() {
_timer?.cancel();
setState(() => _running = false);
}
void _reset() {
_timer?.cancel();
setState(() {
_running = false;
_remainingSeconds = _selectedMinutes * 60;
});
}
void _completeNow() {
_timer?.cancel();
final completedMinutes = _selectedMinutes;
setState(() {
_running = false;
_remainingSeconds = _selectedMinutes * 60;
});
HapticFeedback.mediumImpact();
widget.onComplete(completedMinutes);
showDialog<void>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Session saved'),
content: const Text(
'Take one minute to write what helped you focus.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Later'),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
widget.onReflect();
},
child: const Text('Reflect'),
),
],
),
);
}
}
+93
View File
@@ -0,0 +1,93 @@
import 'focus_session.dart';
import 'habit.dart';
import 'reflection_entry.dart';
class AppState {
const AppState({
required this.anchor,
required this.habits,
required this.sessions,
required this.reflections,
});
final String anchor;
final List<Habit> habits;
final List<FocusSession> sessions;
final List<ReflectionEntry> reflections;
bool get isFreshStart => habits.isEmpty && sessions.isEmpty && anchor.isEmpty;
AppState copyWith({
String? anchor,
List<Habit>? habits,
List<FocusSession>? sessions,
List<ReflectionEntry>? reflections,
}) {
return AppState(
anchor: anchor ?? this.anchor,
habits: habits ?? this.habits,
sessions: sessions ?? this.sessions,
reflections: reflections ?? this.reflections,
);
}
Map<String, dynamic> toJson() {
return {
'version': 1,
'anchor': anchor,
'habits': habits.map((habit) => habit.toJson()).toList(),
'sessions': sessions.map((session) => session.toJson()).toList(),
'reflections':
reflections.map((reflection) => reflection.toJson()).toList(),
};
}
static AppState fromJson(Map<String, dynamic> json) {
return AppState(
anchor: (json['anchor'] as String?) ?? '',
habits: _jsonObjects(json['habits']).map(Habit.fromJson).toList(),
sessions:
_jsonObjects(json['sessions']).map(FocusSession.fromJson).toList(),
reflections:
_jsonObjects(
json['reflections'],
).map(ReflectionEntry.fromJson).toList(),
);
}
static Iterable<Map<String, dynamic>> _jsonObjects(Object? value) {
if (value is! List<Object?>) {
return const Iterable<Map<String, dynamic>>.empty();
}
return value.whereType<Map<String, dynamic>>();
}
static AppState starter() {
final now = DateTime.now();
return AppState(
anchor: '',
habits: [
Habit(
id: 'habit-${now.microsecondsSinceEpoch}-1',
title: 'Plan one clear task',
createdAt: now,
completedDates: {},
),
Habit(
id: 'habit-${now.microsecondsSinceEpoch}-2',
title: 'Focus for 25 minutes',
createdAt: now,
completedDates: {},
),
Habit(
id: 'habit-${now.microsecondsSinceEpoch}-3',
title: 'Write a one-line reflection',
createdAt: now,
completedDates: {},
),
],
sessions: const [],
reflections: const [],
);
}
}
+27
View File
@@ -0,0 +1,27 @@
class FocusSession {
const FocusSession({
required this.id,
required this.minutes,
required this.completedAt,
});
final String id;
final int minutes;
final DateTime completedAt;
Map<String, dynamic> toJson() {
return {
'id': id,
'minutes': minutes,
'completedAt': completedAt.toIso8601String(),
};
}
static FocusSession fromJson(Map<String, dynamic> json) {
return FocusSession(
id: json['id'] as String,
minutes: json['minutes'] as int,
completedAt: DateTime.parse(json['completedAt'] as String),
);
}
}
+43
View File
@@ -0,0 +1,43 @@
class Habit {
const Habit({
required this.id,
required this.title,
required this.createdAt,
required this.completedDates,
});
final String id;
final String title;
final DateTime createdAt;
final Set<String> completedDates;
Habit copyWith({String? title, Set<String>? completedDates}) {
return Habit(
id: id,
title: title ?? this.title,
createdAt: createdAt,
completedDates: completedDates ?? this.completedDates,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'createdAt': createdAt.toIso8601String(),
'completedDates': completedDates.toList()..sort(),
};
}
static Habit fromJson(Map<String, dynamic> json) {
return Habit(
id: json['id'] as String,
title: json['title'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
completedDates:
((json['completedDates'] as List?) ?? const [])
.map((value) => value.toString())
.toSet(),
);
}
}
+13
View File
@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
enum Mood {
calm('Calm', Icons.spa_outlined),
clear('Clear', Icons.wb_sunny_outlined),
proud('Proud', Icons.emoji_events_outlined),
tired('Tired', Icons.bedtime_outlined);
const Mood(this.label, this.icon);
final String label;
final IconData icon;
}
+40
View File
@@ -0,0 +1,40 @@
import 'mood.dart';
class ReflectionEntry {
const ReflectionEntry({
required this.id,
required this.dateKey,
required this.note,
required this.mood,
required this.createdAt,
});
final String id;
final String dateKey;
final String note;
final Mood mood;
final DateTime createdAt;
Map<String, dynamic> toJson() {
return {
'id': id,
'dateKey': dateKey,
'note': note,
'mood': mood.name,
'createdAt': createdAt.toIso8601String(),
};
}
static ReflectionEntry fromJson(Map<String, dynamic> json) {
return ReflectionEntry(
id: json['id'] as String,
dateKey: json['dateKey'] as String,
note: json['note'] as String,
mood: Mood.values.firstWhere(
(mood) => mood.name == json['mood'],
orElse: () => Mood.clear,
),
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
}
+10
View File
@@ -0,0 +1,10 @@
abstract interface class AnalyticsProvider {
void track(String event, [Map<String, Object?> properties = const {}]);
}
class NoopAnalyticsProvider implements AnalyticsProvider {
const NoopAnalyticsProvider();
@override
void track(String event, [Map<String, Object?> properties = const {}]) {}
}
@@ -0,0 +1,46 @@
enum PremiumState { free, premium }
enum AdPlacement { statsBottom, reflectionComplete }
sealed class PurchaseResult {
const PurchaseResult();
static const unavailable = PurchaseUnavailable();
}
class PurchaseUnavailable extends PurchaseResult {
const PurchaseUnavailable();
}
abstract interface class MonetizationProvider {
bool get adsEnabled;
bool get billingEnabled;
Stream<PremiumState> premiumState();
Future<void> showInterstitialIfEligible(AdPlacement placement);
Future<PurchaseResult> startPurchase(String productId);
Future<void> restorePurchases();
}
class NoopMonetizationProvider implements MonetizationProvider {
const NoopMonetizationProvider();
@override
bool get adsEnabled => false;
@override
bool get billingEnabled => false;
@override
Stream<PremiumState> premiumState() => Stream.value(PremiumState.free);
@override
Future<void> showInterstitialIfEligible(AdPlacement placement) async {}
@override
Future<PurchaseResult> startPurchase(String productId) async {
return PurchaseResult.unavailable;
}
@override
Future<void> restorePurchases() async {}
}
+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(),
),
],
),
);
}
}