diff --git a/.gitignore b/.gitignore index 428bfb9..7f5f818 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,5 @@ pubspec.lock ios/Podfile.lock linux/flutter/generated_plugin_registrant.cc lib/features/contract/data/contract_record.g.dart +lib/features/settings/data/user_profile_record.g.dart lib/features/contract/data/reminder_record.g.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index a3dfff0..8a2d8a6 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,17 +1,77 @@ -import 'package:du/core/theme/app_theme.dart'; +import 'package:du/app/router.dart'; import 'package:flutter/material.dart'; -import 'router.dart'; +import 'package:du/app/controllers/theme_controller.dart'; -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { const MyApp({super.key}); + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + final ThemeController _themeController = ThemeController.instance; + + @override + void initState() { + super.initState(); + _themeController.init(); + } + + ThemeData _buildLightTheme() { + const seed = Color(0xFF4F46E5); + return ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: seed, brightness: Brightness.light), + useMaterial3: true, + scaffoldBackgroundColor: const Color(0xFFF5F6FB), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + foregroundColor: Colors.black87, + elevation: 0, + centerTitle: false, + ), + cardTheme: CardThemeData( + color: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + elevation: 0, + ), + ); + } + + ThemeData _buildDarkTheme() { + const seed = Color(0xFF818CF8); + return ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: seed, brightness: Brightness.dark), + useMaterial3: true, + scaffoldBackgroundColor: const Color(0xFF0F172A), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + elevation: 0, + centerTitle: false, + ), + cardTheme: CardThemeData( + color: const Color(0xFF1E293B), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + elevation: 0, + ), + ); + } + @override Widget build(BuildContext context) { - return MaterialApp.router( - title: 'Du', - routerConfig: appRouter, - theme: appDarkTheme, - debugShowCheckedModeBanner: false, + return ValueListenableBuilder( + valueListenable: _themeController.mode, + builder: (_, mode, __) { + return MaterialApp.router( + title: 'Du', + routerConfig: appRouter, + debugShowCheckedModeBanner: false, + theme: _buildLightTheme(), + darkTheme: _buildDarkTheme(), + themeMode: mode, + ); + }, ); } } diff --git a/lib/app/controllers/theme_controller.dart b/lib/app/controllers/theme_controller.dart new file mode 100644 index 0000000..7e3aa6b --- /dev/null +++ b/lib/app/controllers/theme_controller.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:du/features/settings/data/profile_repository.dart'; + +class ThemeController { + ThemeController._internal(); + + static final ThemeController instance = ThemeController._internal(); + + final ValueNotifier mode = ValueNotifier(ThemeMode.light); + final ProfileRepository _repository = ProfileRepository(); + + bool _initialized = false; + int? _profileId; + + Future init() async { + if (_initialized) return; + final profile = await _repository.loadOrCreate(); + _profileId = profile.id; + mode.value = profile.themeMode; + _initialized = true; + } + + Future setDarkMode(bool dark) async { + await _ensureProfile(); + final profile = await _repository.updateTheme(_profileId!, dark); + mode.value = profile.themeMode; + return profile; + } + + Future loadProfile() async { + await _ensureProfile(); + return _repository.loadOrCreate(); + } + + Future _ensureProfile() async { + if (!_initialized) { + await init(); + } + _profileId ??= (await _repository.loadOrCreate()).id; + } +} diff --git a/lib/app/router.dart b/lib/app/router.dart index 65259fa..0f3c53c 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -1,6 +1,7 @@ import 'package:go_router/go_router.dart'; import 'package:du/features/home/view/home_page.dart'; import 'package:du/features/contract/view/contract_page.dart'; +import 'package:du/features/settings/view/settings_page.dart'; final GoRouter appRouter = GoRouter( routes: [ @@ -12,5 +13,9 @@ final GoRouter appRouter = GoRouter( path: '/contract', builder: (context, state) => const ContractPage(), ), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + ), ], ); diff --git a/lib/core/db/isar_db.dart b/lib/core/db/isar_db.dart index bf6ee20..85f59b5 100644 --- a/lib/core/db/isar_db.dart +++ b/lib/core/db/isar_db.dart @@ -1,7 +1,8 @@ +import 'package:du/features/contract/data/reminder_record.dart'; import 'package:isar/isar.dart'; import 'package:path_provider/path_provider.dart'; import 'package:du/features/contract/data/contract_record.dart'; -import 'package:du/features/contract/data/reminder_record.dart'; +import 'package:du/features/settings/data/user_profile_record.dart'; class AppDb { static Isar? _instance; @@ -10,10 +11,7 @@ class AppDb { if (_instance != null) return _instance!; final dir = await getApplicationDocumentsDirectory(); _instance = await Isar.open( - [ - ContractRecordSchema, - ReminderRecordSchema - ], + [ContractRecordSchema, ReminderRecordSchema, UserProfileRecordSchema], directory: dir.path, inspector: !bool.fromEnvironment('dart.vm.product'), ); diff --git a/lib/features/contract/data/repositories/contract_repository.dart b/lib/features/contract/data/repositories/contract_repository.dart index f945d8a..dc4b330 100644 --- a/lib/features/contract/data/repositories/contract_repository.dart +++ b/lib/features/contract/data/repositories/contract_repository.dart @@ -19,6 +19,26 @@ class ContractSummary { }); } +class ContractAnalytics { + final int totalContracts; + final int succeededContracts; + final int failedContracts; + final double commitmentRate; + final double averagePerDay; + final DateTime? firstContractDate; + + const ContractAnalytics({ + required this.totalContracts, + required this.succeededContracts, + required this.failedContracts, + required this.commitmentRate, + required this.averagePerDay, + required this.firstContractDate, + }); + + int get completedContracts => succeededContracts + failedContracts; +} + class ContractRepository { Future get _db => AppDb.instance(); @@ -166,4 +186,38 @@ class ContractRepository { await isar.contractRecords.delete(id); }); } + + Future analytics() async { + final isar = await _db; + + final total = await isar.contractRecords.count(); + final succeeded = + await isar.contractRecords.filter().succeededEqualTo(true).count(); + final failed = + await isar.contractRecords.filter().succeededEqualTo(false).count(); + + final completed = succeeded + failed; + final double commitmentRate = completed == 0 + ? 0 + : succeeded / completed; + + final firstRecord = + await isar.contractRecords.where().sortByCreatedAt().findFirst(); + + double averagePerDay = 0; + if (total > 0 && firstRecord != null) { + final now = DateTime.now(); + final spanDays = now.difference(firstRecord.createdAt ?? DateTime.now()).inDays + 1; + averagePerDay = total / spanDays; + } + + return ContractAnalytics( + totalContracts: total, + succeededContracts: succeeded, + failedContracts: failed, + commitmentRate: commitmentRate, + averagePerDay: averagePerDay, + firstContractDate: firstRecord?.createdAt, + ); + } } diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart index 4f3afe7..40af924 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -159,7 +159,16 @@ class _HomePageState extends State { final items = _itemsForFilter(st); return Scaffold( - appBar: AppBar(title: Text(_titleForFilter())), + appBar: AppBar( + title: Text(_titleForFilter()), + actions: [ + IconButton( + tooltip: 'Profile & Settings', + icon: const Icon(Icons.person_outline), + onPressed: () => context.push('/settings'), + ), + ], + ), body: Column( children: [ Padding( diff --git a/lib/features/settings/controller/settings_controller.dart b/lib/features/settings/controller/settings_controller.dart new file mode 100644 index 0000000..6b56b27 --- /dev/null +++ b/lib/features/settings/controller/settings_controller.dart @@ -0,0 +1,83 @@ +import 'package:du/app/controllers/theme_controller.dart'; +import 'package:du/features/contract/data/repositories/contract_repository.dart'; +import 'package:du/features/settings/data/profile_repository.dart'; +import 'package:du/features/settings/model/settings_state.dart'; +import 'package:flutter/foundation.dart'; + +class SettingsController { + final ProfileRepository _profileRepository = ProfileRepository(); + final ContractRepository _contractRepository = ContractRepository(); + final ThemeController _themeController = ThemeController.instance; + + final ValueNotifier state = + ValueNotifier(SettingsState.initial()); + + UserProfile? _profile; + + Future init() async { + final profile = await _themeController.loadProfile(); + _profile = profile; + + final analytics = await _contractRepository.analytics(); + + state.value = SettingsState( + loading: false, + name: profile.name, + darkMode: profile.darkMode, + commitmentRate: analytics.commitmentRate, + completedContracts: analytics.completedContracts, + succeededContracts: analytics.succeededContracts, + failedContracts: analytics.failedContracts, + averagePerDay: analytics.averagePerDay, + totalContracts: analytics.totalContracts, + firstContractDate: analytics.firstContractDate, + savingName: false, + togglingTheme: false, + ); + } + + Future refreshAnalytics() async { + final analytics = await _contractRepository.analytics(); + state.value = state.value.copyWith( + commitmentRate: analytics.commitmentRate, + completedContracts: analytics.completedContracts, + succeededContracts: analytics.succeededContracts, + failedContracts: analytics.failedContracts, + averagePerDay: analytics.averagePerDay, + totalContracts: analytics.totalContracts, + firstContractDate: analytics.firstContractDate, + ); + } + + Future updateName(String name) async { + if (_profile == null || state.value.savingName) return null; + state.value = state.value.copyWith(savingName: true); + + final updated = await _profileRepository.updateName(_profile!.id, name); + _profile = updated; + + state.value = state.value.copyWith( + name: updated.name, + savingName: false, + ); + return updated; + } + + Future toggleDarkMode(bool value) async { + if (_profile == null || state.value.togglingTheme) return null; + state.value = state.value.copyWith(togglingTheme: true); + + final updated = await _themeController.setDarkMode(value); + _profile = updated; + + state.value = state.value.copyWith( + darkMode: updated.darkMode, + togglingTheme: false, + ); + return updated; + } + + void dispose() { + state.dispose(); + } +} diff --git a/lib/features/settings/data/profile_repository.dart b/lib/features/settings/data/profile_repository.dart new file mode 100644 index 0000000..f813833 --- /dev/null +++ b/lib/features/settings/data/profile_repository.dart @@ -0,0 +1,95 @@ +import 'package:du/core/db/isar_db.dart'; +import 'package:du/features/settings/data/user_profile_record.dart'; +import 'package:flutter/material.dart'; +import 'package:isar/isar.dart'; + +class UserProfile { + final int id; + final String name; + final bool darkMode; + + const UserProfile({ + required this.id, + required this.name, + required this.darkMode, + }); + + ThemeMode get themeMode => darkMode ? ThemeMode.dark : ThemeMode.light; +} + +class ProfileRepository { + Future get _db => AppDb.instance(); + + Future loadOrCreate() async { + final isar = await _db; + final existing = await isar.userProfileRecords.where().findFirst(); + if (existing != null) { + return _map(existing); + } + + final record = UserProfileRecord() + ..name = 'Trailblazer' + ..darkMode = false; + + final id = await isar.writeTxn(() async => await isar.userProfileRecords.put(record)); + final saved = await isar.userProfileRecords.get(id); + return _map(saved ?? record..id = id); + } + + Future updateName(int id, String name) async { + final trimmed = name.trim(); + final fallback = trimmed.isEmpty ? 'Trailblazer' : trimmed; + final isar = await _db; + + final updated = await isar.writeTxn(() async { + final record = await isar.userProfileRecords.get(id); + if (record == null) return null; + record + ..name = fallback + ..updatedAt = DateTime.now(); + await isar.userProfileRecords.put(record); + return record; + }); + + return _map(updated ?? (await loadOrCreateRecord())); + } + + Future updateTheme(int id, bool darkMode) async { + final isar = await _db; + final updated = await isar.writeTxn(() async { + final record = await isar.userProfileRecords.get(id); + if (record == null) return null; + record + ..darkMode = darkMode + ..updatedAt = DateTime.now(); + await isar.userProfileRecords.put(record); + return record; + }); + + return _map(updated ?? (await loadOrCreateRecord())); + } + + Future loadOrCreateRecord() async { + final isar = await _db; + final existing = await isar.userProfileRecords.where().findFirst(); + if (existing != null) { + return existing; + } + + final record = UserProfileRecord() + ..name = 'Trailblazer' + ..darkMode = false; + + final id = await isar.writeTxn(() async => await isar.userProfileRecords.put(record)); + final saved = await isar.userProfileRecords.get(id); + return saved ?? record..id = id; + } + + UserProfile _map(UserProfileRecord record) { + return UserProfile( + id: record.id, + name: record.name, + darkMode: record.darkMode, + ); + } +} diff --git a/lib/features/settings/data/user_profile_record.dart b/lib/features/settings/data/user_profile_record.dart new file mode 100644 index 0000000..5ebd615 --- /dev/null +++ b/lib/features/settings/data/user_profile_record.dart @@ -0,0 +1,12 @@ +import 'package:isar/isar.dart'; + +part 'user_profile_record.g.dart'; + +@collection +class UserProfileRecord { + Id id = Isar.autoIncrement; + late String name; + bool darkMode = false; + DateTime createdAt = DateTime.now(); + DateTime updatedAt = DateTime.now(); +} diff --git a/lib/features/settings/model/settings_state.dart b/lib/features/settings/model/settings_state.dart new file mode 100644 index 0000000..73bda43 --- /dev/null +++ b/lib/features/settings/model/settings_state.dart @@ -0,0 +1,78 @@ +class SettingsState { + final bool loading; + final String name; + final bool darkMode; + final double commitmentRate; + final int completedContracts; + final int succeededContracts; + final int failedContracts; + final double averagePerDay; + final int totalContracts; + final DateTime? firstContractDate; + final bool savingName; + final bool togglingTheme; + + const SettingsState({ + required this.loading, + required this.name, + required this.darkMode, + required this.commitmentRate, + required this.completedContracts, + required this.succeededContracts, + required this.failedContracts, + required this.averagePerDay, + required this.totalContracts, + required this.firstContractDate, + required this.savingName, + required this.togglingTheme, + }); + + double get commitmentPercentage => commitmentRate * 100; + + bool get canSaveName => !savingName; + + SettingsState copyWith({ + bool? loading, + String? name, + bool? darkMode, + double? commitmentRate, + int? completedContracts, + int? succeededContracts, + int? failedContracts, + double? averagePerDay, + int? totalContracts, + DateTime? firstContractDate, + bool? savingName, + bool? togglingTheme, + }) { + return SettingsState( + loading: loading ?? this.loading, + name: name ?? this.name, + darkMode: darkMode ?? this.darkMode, + commitmentRate: commitmentRate ?? this.commitmentRate, + completedContracts: completedContracts ?? this.completedContracts, + succeededContracts: succeededContracts ?? this.succeededContracts, + failedContracts: failedContracts ?? this.failedContracts, + averagePerDay: averagePerDay ?? this.averagePerDay, + totalContracts: totalContracts ?? this.totalContracts, + firstContractDate: firstContractDate ?? this.firstContractDate, + savingName: savingName ?? this.savingName, + togglingTheme: togglingTheme ?? this.togglingTheme, + ); + } + + static SettingsState initial() => const SettingsState( + loading: true, + name: '', + darkMode: false, + commitmentRate: 0, + completedContracts: 0, + succeededContracts: 0, + failedContracts: 0, + averagePerDay: 0, + totalContracts: 0, + firstContractDate: null, + savingName: false, + togglingTheme: false, + ); +} diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart new file mode 100644 index 0000000..c42a82d --- /dev/null +++ b/lib/features/settings/view/settings_page.dart @@ -0,0 +1,787 @@ +import 'dart:math' as math; + +import 'package:du/features/settings/controller/settings_controller.dart'; +import 'package:du/features/settings/model/settings_state.dart'; +import 'package:flutter/material.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + late final SettingsController _controller; + late final TextEditingController _nameController; + late final FocusNode _nameFocus; + + @override + void initState() { + super.initState(); + _controller = SettingsController(); + _nameController = TextEditingController(); + _nameFocus = FocusNode(); + _controller.state.addListener(_onStateChanged); + _controller.init(); + } + + @override + void dispose() { + _controller.state.removeListener(_onStateChanged); + _controller.dispose(); + _nameController.dispose(); + _nameFocus.dispose(); + super.dispose(); + } + + void _onStateChanged() { + final state = _controller.state.value; + if (state.loading || _nameFocus.hasFocus) return; + if (_nameController.text != state.name) { + _nameController.text = state.name; + } + } + + Future _saveName() async { + final trimmed = _nameController.text.trim(); + if (trimmed.isEmpty) return; + FocusScope.of(context).unfocus(); + final saved = await _controller.updateName(trimmed); + if (!mounted || saved == null) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Updated profile name to ${saved.name}')), + ); + } + + Future _toggleTheme(bool value) async { + await _controller.toggleDarkMode(value); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _controller.state, + builder: (context, state, _) { + final theme = Theme.of(context); + final gradient = LinearGradient( + colors: theme.brightness == Brightness.dark + ? const [Color(0xFF0B1120), Color(0xFF1E1B4B)] + : const [Color(0xFFEFF3FF), Color(0xFFDDE4FF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + + if (state.loading) { + return Scaffold( + appBar: AppBar(title: const Text('Profile & Settings')), + body: Container( + decoration: BoxDecoration(gradient: gradient), + child: const Center(child: CircularProgressIndicator()), + ), + ); + } + + final displayName = state.name.trim().isEmpty ? 'Trailblazer' : state.name.trim(); + final pendingName = _nameController.text.trim(); + final canSaveName = state.canSaveName && pendingName.isNotEmpty && pendingName != state.name; + + return Scaffold( + appBar: AppBar(title: const Text('Profile & Settings')), + body: Container( + decoration: BoxDecoration(gradient: gradient), + child: RefreshIndicator( + onRefresh: _controller.refreshAnalytics, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Header(displayName: displayName, state: state), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + _IdentityCard( + controller: _nameController, + focusNode: _nameFocus, + onSave: canSaveName ? () => _saveName() : null, + saving: state.savingName, + ), + const SizedBox(height: 20), + _ThemeCard( + value: state.darkMode, + onChanged: state.togglingTheme + ? null + : (value) { + _toggleTheme(value); + }, + ), + const SizedBox(height: 20), + _InsightsGrid(state: state), + const SizedBox(height: 20), + _TimelineCard(firstDate: state.firstContractDate, total: state.totalContracts), + const SizedBox(height: 24), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _Header extends StatelessWidget { + const _Header({required this.displayName, required this.state}); + + final String displayName; + final SettingsState state; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final initials = displayName.trim().isNotEmpty ? displayName.trim()[0].toUpperCase() : 'T'; + + final accent = theme.colorScheme.primary; + final bg = LinearGradient( + colors: theme.brightness == Brightness.dark + ? [const Color(0xFF1E1B4B), const Color(0xFF312E81)] + : [const Color(0xFF6366F1), const Color(0xFF8B5CF6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Container( + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + gradient: bg, + boxShadow: [ + BoxShadow( + color: accent.withOpacity(0.25), + blurRadius: 28, + offset: const Offset(0, 20), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 40, + backgroundColor: Colors.white.withOpacity(0.22), + child: CircleAvatar( + radius: 34, + backgroundColor: Colors.white, + child: Text( + initials, + style: theme.textTheme.headlineMedium?.copyWith( + color: accent, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 24), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Welcome back', + style: theme.textTheme.titleMedium?.copyWith( + color: Colors.white70, + ), + ), + const SizedBox(height: 6), + Text( + displayName, + style: theme.textTheme.headlineSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 12), + Text( + 'Your commitments are shaping a reliable streak.', + style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white.withOpacity(0.85)), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _HeroChip(icon: Icons.bolt, label: '${state.totalContracts} total'), + _HeroChip( + icon: Icons.verified_outlined, + label: state.completedContracts == 0 + ? 'No completions yet' + : '${(state.commitmentRate * 100).toStringAsFixed(0)}% commitment', + ), + _HeroChip( + icon: Icons.auto_graph, + label: state.averagePerDay == 0 + ? 'Start a new contract' + : '${state.averagePerDay.toStringAsFixed(2)} per day', + ), + ], + ), + ], + ), + ), + ); + } +} + +class _HeroChip extends StatelessWidget { + const _HeroChip({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.18), + borderRadius: BorderRadius.circular(40), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18, color: Colors.white), + const SizedBox(width: 8), + Text( + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _IdentityCard extends StatelessWidget { + const _IdentityCard({ + required this.controller, + required this.focusNode, + required this.onSave, + required this.saving, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final VoidCallback? onSave; + final bool saving; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return _SettingsCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Profile', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + TextField( + controller: controller, + focusNode: focusNode, + textCapitalization: TextCapitalization.words, + decoration: InputDecoration( + labelText: 'Display name', + prefixIcon: const Icon(Icons.person_outline), + suffixIcon: saving + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2.6), + ), + ) + : null, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + icon: const Icon(Icons.save_outlined), + label: const Text('Save name'), + onPressed: onSave, + ), + ), + ], + ), + ); + } +} + +class _ThemeCard extends StatelessWidget { + const _ThemeCard({required this.value, required this.onChanged}); + + final bool value; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return _SettingsCard( + child: SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: Text( + 'Dark mode', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ), + subtitle: Text( + value ? 'For late-night focus and deep contrast.' : 'A bright palette for daytime clarity.', + ), + secondary: CircleAvatar( + radius: 22, + backgroundColor: theme.colorScheme.primary.withOpacity(0.15), + child: Icon( + value ? Icons.nightlight_round : Icons.wb_sunny_outlined, + color: theme.colorScheme.primary, + ), + ), + value: value, + onChanged: onChanged, + ), + ); + } +} + +class _InsightsGrid extends StatelessWidget { + const _InsightsGrid({required this.state}); + + final SettingsState state; + + @override + Widget build(BuildContext context) { + final commitment = state.commitmentRate.clamp(0.0, 1.0).toDouble(); + final normalizedAverage = state.averagePerDay == 0 + ? 0.0 + : (state.averagePerDay / 3).clamp(0.0, 1.0).toDouble(); + + return LayoutBuilder( + builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 700; + final cards = [ + _CommitmentCard(commitment: commitment, state: state), + _AverageCard(averagePerDay: state.averagePerDay, normalized: normalizedAverage, total: state.totalContracts), + _BreakdownCard(state: state), + ]; + + if (isNarrow) { + return Column( + children: [ + for (int i = 0; i < cards.length; i++) ...[ + cards[i], + if (i != cards.length - 1) const SizedBox(height: 16), + ], + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: cards[0]), + const SizedBox(width: 16), + Expanded(child: cards[1]), + const SizedBox(width: 16), + Expanded(child: cards[2]), + ], + ); + }, + ); + } +} + +class _CommitmentCard extends StatelessWidget { + const _CommitmentCard({required this.commitment, required this.state}); + + final double commitment; + final SettingsState state; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final subtitle = state.completedContracts == 0 + ? 'Complete a contract to establish your commitment streak.' + : '${state.succeededContracts} successes out of ${state.completedContracts} completed.'; + + return _SettingsCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Commitment', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 18), + Center( + child: SizedBox( + height: 150, + width: 150, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + value: 1, + strokeWidth: 12, + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.surfaceContainerHighest.withOpacity( + theme.brightness == Brightness.dark ? 0.35 : 0.2, + ), + ), + ), + TweenAnimationBuilder( + tween: Tween(begin: 0, end: commitment), + duration: const Duration(milliseconds: 600), + curve: Curves.easeOutCubic, + builder: (context, value, child) { + return CircularProgressIndicator( + value: value, + strokeCap: StrokeCap.round, + strokeWidth: 12, + backgroundColor: Colors.transparent, + valueColor: AlwaysStoppedAnimation(theme.colorScheme.primary), + ); + }, + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${(commitment * 100).round()}%', + style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 4), + Text('successful', style: theme.textTheme.labelLarge), + ], + ) + ], + ), + ), + ), + const SizedBox(height: 16), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.textTheme.bodyMedium?.color?.withOpacity(0.7), + ), + ), + ], + ), + ); + } +} + +class _AverageCard extends StatelessWidget { + const _AverageCard({ + required this.averagePerDay, + required this.normalized, + required this.total, + }); + + final double averagePerDay; + final double normalized; + final int total; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final accent = theme.colorScheme.secondary; + final comparison = math.max(averagePerDay, 1.0); + final comparisonLabel = comparison <= 1.0 + ? 'Build a streak by creating regularly.' + : 'Consistent! Keep the pace going.'; + + return _SettingsCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Daily rhythm', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TweenAnimationBuilder( + tween: Tween(begin: 0, end: averagePerDay), + duration: const Duration(milliseconds: 600), + builder: (context, value, _) { + return Text( + value.toStringAsFixed(2), + style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w700), + ); + }, + ), + const SizedBox(height: 4), + Text( + 'contracts created per day on average', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.textTheme.bodyMedium?.color?.withOpacity(0.7), + ), + ), + ], + ), + ), + Container( + height: 60, + width: 60, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [accent.withOpacity(0.85), theme.colorScheme.primary], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: const Icon(Icons.show_chart, color: Colors.white), + ), + ], + ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: LinearProgressIndicator( + value: normalized, + minHeight: 10, + backgroundColor: theme.colorScheme.surfaceContainerHighest.withOpacity( + theme.brightness == Brightness.dark ? 0.3 : 0.2, + ), + valueColor: AlwaysStoppedAnimation(accent), + ), + ), + const SizedBox(height: 12), + Text( + total == 0 + ? 'Let’s start by creating your first commitment.' + : comparisonLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.textTheme.bodySmall?.color?.withOpacity(0.7), + ), + ), + ], + ), + ); + } +} + +class _BreakdownCard extends StatelessWidget { + const _BreakdownCard({required this.state}); + + final SettingsState state; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final successColor = Colors.greenAccent.withOpacity(theme.brightness == Brightness.dark ? 0.3 : 0.4); + final failColor = Colors.redAccent.withOpacity(theme.brightness == Brightness.dark ? 0.28 : 0.38); + + return _SettingsCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Outcomes', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _OutcomeTile( + label: 'Succeeded', + count: state.succeededContracts, + accent: successColor, + icon: Icons.check_circle_outline, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _OutcomeTile( + label: 'Failed', + count: state.failedContracts, + accent: failColor, + icon: Icons.cancel_outlined, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Track how each contract ends to fine-tune your goals.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.textTheme.bodySmall?.color?.withOpacity(0.7), + ), + ), + ], + ), + ); + } +} + +class _OutcomeTile extends StatelessWidget { + const _OutcomeTile({ + required this.label, + required this.count, + required this.accent, + required this.icon, + }); + + final String label; + final int count; + final Color accent; + final IconData icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textColor = theme.brightness == Brightness.dark ? Colors.white : Colors.black87; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: accent, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: textColor), + const SizedBox(height: 12), + Text( + count.toString(), + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + color: textColor, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: theme.textTheme.labelLarge?.copyWith(color: textColor), + ), + ], + ), + ); + } +} + +class _TimelineCard extends StatelessWidget { + const _TimelineCard({required this.firstDate, required this.total}); + + final DateTime? firstDate; + final int total; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final message = total == 0 + ? 'You have a fresh slate. Create a contract to begin your journey.' + : 'You kicked things off on ${_formatDate(firstDate)}.'; + + return _SettingsCard( + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + leading: CircleAvatar( + radius: 28, + backgroundColor: theme.colorScheme.primary.withOpacity(0.12), + child: Icon(Icons.calendar_month, color: theme.colorScheme.primary), + ), + title: Text( + 'Journey timeline', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(message), + ), + ), + ); + } + + String _formatDate(DateTime? date) { + if (date == null) { + return 'today'; + } + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + final month = months[date.month - 1]; + return '$month ${date.day}, ${date.year}'; + } +} + +class _SettingsCard extends StatelessWidget { + const _SettingsCard({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + color: theme.cardTheme.color?.withOpacity(theme.brightness == Brightness.dark ? 0.92 : 0.96) ?? + theme.colorScheme.surface.withOpacity(theme.brightness == Brightness.dark ? 0.92 : 0.96), + border: Border.all( + color: theme.colorScheme.primary.withOpacity(0.08), + ), + ), + child: child, + ); + } +}