diff --git a/.gitignore b/.gitignore index 402da67..fd7d18b 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ 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 diff --git a/lib/app/app.dart b/lib/app/app.dart index da2a400..7faf85c 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,15 +1,77 @@ import 'package:flutter/material.dart'; +import 'package:du/app/controllers/theme_controller.dart'; import 'router.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: CardTheme( + 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: CardTheme( + color: const Color(0xFF1E293B), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + elevation: 0, + ), + ); + } + @override Widget build(BuildContext context) { - return MaterialApp.router( - title: 'Du', - routerConfig: appRouter, - 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 c6d6238..b190684 100644 --- a/lib/core/db/isar_db.dart +++ b/lib/core/db/isar_db.dart @@ -1,6 +1,7 @@ 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/settings/data/user_profile_record.dart'; class AppDb { static Isar? _instance; @@ -9,7 +10,7 @@ class AppDb { if (_instance != null) return _instance!; final dir = await getApplicationDocumentsDirectory(); _instance = await Isar.open( - [ContractRecordSchema], + [ContractRecordSchema, 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 da2a0be..3e03abb 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(); @@ -124,4 +144,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 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).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 48740f3..5b4f9eb 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -121,7 +121,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..692f8f0 --- /dev/null +++ b/lib/features/settings/controller/settings_controller.dart @@ -0,0 +1,61 @@ +import 'package:du/app/controllers/theme_controller.dart'; +import 'package:du/features/contract/data/repositories/contract_repository.dart'; +import 'package:du/features/settings/model/settings_state.dart'; +import 'package:flutter/foundation.dart'; + +class SettingsController { + final ContractRepository _contractRepository = ContractRepository(); + final ThemeController _themeController = ThemeController.instance; + + final ValueNotifier state = + ValueNotifier(SettingsState.initial()); + + Future init() async { + final profile = await _themeController.loadProfile(); + + 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, + 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 toggleDarkMode(bool value) async { + if (state.value.togglingTheme) return; + state.value = state.value.copyWith(togglingTheme: true); + + final updated = await _themeController.setDarkMode(value); + + state.value = state.value.copyWith( + darkMode: updated.darkMode, + togglingTheme: false, + ); + } + + 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..6de6632 --- /dev/null +++ b/lib/features/settings/model/settings_state.dart @@ -0,0 +1,71 @@ +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 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.togglingTheme, + }); + + double get commitmentPercentage => commitmentRate * 100; + + SettingsState copyWith({ + bool? loading, + String? name, + bool? darkMode, + double? commitmentRate, + int? completedContracts, + int? succeededContracts, + int? failedContracts, + double? averagePerDay, + int? totalContracts, + DateTime? firstContractDate, + 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, + 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, + 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..dafc924 --- /dev/null +++ b/lib/features/settings/view/settings_page.dart @@ -0,0 +1,894 @@ +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; + + @override + void initState() { + super.initState(); + _controller = SettingsController(); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + 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(); + + 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: [ + _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 _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 cards = [ + _CommitmentCard( + commitment: commitment, + succeeded: state.succeededContracts, + completed: state.completedContracts, + ), + _AverageCard( + averagePerDay: state.averagePerDay, + total: state.totalContracts, + ), + _BreakdownCard(state: state), + ]; + + return LayoutBuilder( + builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 700; + + 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.succeeded, + required this.completed, + }); + + final double commitment; + final int succeeded; + final int completed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final safeCommitment = commitment.clamp(0.0, 1.0); + final subtitle = completed == 0 + ? 'Complete your first contract to begin charting commitment.' + : '$succeeded successes out of $completed completed.'; + + return _SettingsCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.flag_rounded, color: theme.colorScheme.primary), + const SizedBox(width: 12), + Text( + 'Commitment rate', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + const SizedBox(height: 24), + SizedBox( + height: 190, + child: TweenAnimationBuilder( + tween: Tween(begin: 0, end: safeCommitment), + duration: const Duration(milliseconds: 900), + curve: Curves.easeOutCubic, + builder: (context, value, _) { + return CustomPaint( + painter: _CommitmentGaugePainter( + progress: value, + accent: theme.colorScheme.primary, + trackColor: theme.colorScheme.surfaceVariant.withOpacity( + theme.brightness == Brightness.dark ? 0.35 : 0.25, + ), + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${(value * 100).clamp(0, 100).toStringAsFixed(0)}%', + style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Text( + 'of completed commitments succeeded', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.textTheme.bodySmall?.color?.withOpacity(0.7), + ), + ), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 8, + children: [ + _StatChip( + icon: Icons.check_circle, + label: '$succeeded succeeded', + ), + _StatChip( + icon: Icons.task_alt_outlined, + label: '$completed completed', + ), + ], + ), + const SizedBox(height: 12), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.textTheme.bodySmall?.color?.withOpacity(0.7), + ), + ), + ], + ), + ); + } +} + +class _AverageCard extends StatelessWidget { + const _AverageCard({ + required this.averagePerDay, + required this.total, + }); + + final double averagePerDay; + final int total; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final normalized = (averagePerDay / 4).clamp(0.0, 1.0); + final tone = theme.colorScheme.tertiary; + final message = total == 0 + ? 'Start your first contract to discover your rhythm.' + : averagePerDay < 1 + ? 'Try scheduling consistent commitments to build momentum.' + : averagePerDay < 2.5 + ? 'Nice cadence — you are establishing a steady habit.' + : 'High-energy pace! Remember to balance focus and rest.'; + + return _SettingsCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.auto_graph_rounded, color: tone), + const SizedBox(width: 12), + Text( + 'Daily rhythm', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceVariant.withOpacity( + theme.brightness == Brightness.dark ? 0.6 : 0.4, + ), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '$total total', + style: theme.textTheme.labelMedium, + ), + ), + ], + ), + const SizedBox(height: 18), + TweenAnimationBuilder( + tween: Tween(begin: 0, end: normalized), + duration: const Duration(milliseconds: 900), + curve: Curves.easeOutCubic, + builder: (context, value, _) { + return SizedBox( + height: 140, + child: CustomPaint( + painter: _DailyRhythmPainter( + progress: value, + accent: tone, + baseColor: theme.colorScheme.surfaceVariant.withOpacity( + theme.brightness == Brightness.dark ? 0.25 : 0.18, + ), + ), + ), + ); + }, + ), + const SizedBox(height: 16), + Text.rich( + TextSpan( + style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w700), + children: [ + TextSpan(text: averagePerDay.toStringAsFixed(2)), + TextSpan( + text: ' / day', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.textTheme.bodyMedium?.color?.withOpacity(0.7), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Text( + message, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.textTheme.bodySmall?.color?.withOpacity(0.7), + ), + ), + ], + ), + ); + } +} + +class _StatChip extends StatelessWidget { + const _StatChip({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final background = theme.colorScheme.primaryContainer.withOpacity( + theme.brightness == Brightness.dark ? 0.7 : 1, + ); + final textColor = theme.colorScheme.onPrimaryContainer; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + color: background, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: textColor), + const SizedBox(width: 8), + Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ], + ), + ); + } +} + +class _CommitmentGaugePainter extends CustomPainter { + const _CommitmentGaugePainter({ + required this.progress, + required this.accent, + required this.trackColor, + }); + + final double progress; + final Color accent; + final Color trackColor; + + @override + void paint(Canvas canvas, Size size) { + final shortestSide = math.min(size.width, size.height); + final center = Offset(size.width / 2, size.height / 2); + final radius = shortestSide / 2 - 12; + final strokeWidth = radius * 0.22; + + final trackPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round + ..color = trackColor; + + canvas.drawCircle(center, radius, trackPaint); + + final safeProgress = progress.clamp(0.0, 1.0); + if (safeProgress <= 0) { + final innerPaint = Paint()..color = accent.withOpacity(0.08); + canvas.drawCircle(center, radius * 0.65, innerPaint); + return; + } + + final sweep = 2 * math.pi * safeProgress; + final startAngle = -math.pi / 2; + final shader = SweepGradient( + startAngle: startAngle, + endAngle: startAngle + 2 * math.pi, + colors: [ + accent.withOpacity(0.2), + accent, + ], + ).createShader(Rect.fromCircle(center: center, radius: radius)); + + final progressPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round + ..shader = shader; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + startAngle, + sweep, + false, + progressPaint, + ); + + final innerPaint = Paint()..color = accent.withOpacity(0.08); + canvas.drawCircle(center, radius * 0.65, innerPaint); + } + + @override + bool shouldRepaint(covariant _CommitmentGaugePainter oldDelegate) { + return oldDelegate.progress != progress || + oldDelegate.accent != accent || + oldDelegate.trackColor != trackColor; + } +} + +class _DailyRhythmPainter extends CustomPainter { + _DailyRhythmPainter({ + required this.progress, + required this.accent, + required this.baseColor, + }); + + final double progress; + final Color accent; + final Color baseColor; + + @override + void paint(Canvas canvas, Size size) { + final bars = 7; + final rect = RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, size.width, size.height), + const Radius.circular(18), + ); + + final backgroundPaint = Paint()..color = baseColor; + canvas.drawRRect(rect, backgroundPaint); + + canvas.save(); + canvas.clipRRect(rect); + + final spacing = size.width / (bars * 2 - 1); + final barWidth = spacing * 0.9; + final startOffset = (spacing - barWidth) / 2; + final baseline = size.height; + final normalized = progress.clamp(0.0, 1.0); + + final barPaint = Paint() + ..style = PaintingStyle.fill + ..shader = LinearGradient( + colors: [accent.withOpacity(0.45), accent], + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + ).createShader(rect.outerRect); + + for (var i = 0; i < bars; i++) { + final factor = (i + 1) / bars; + final heightFactor = (0.2 + normalized * (0.6 + 0.2 * factor)).clamp(0.12, 1.0); + final barHeight = size.height * heightFactor; + final dx = i * spacing * 2 + startOffset; + final barRect = RRect.fromRectAndRadius( + Rect.fromLTWH(dx, baseline - barHeight, barWidth, barHeight), + const Radius.circular(8), + ); + canvas.drawRRect(barRect, barPaint); + } + + final gridPaint = Paint() + ..color = accent.withOpacity(0.15) + ..strokeWidth = 1.2; + + for (var i = 1; i <= 3; i++) { + final dy = baseline - (size.height * (i / 3)); + canvas.drawLine(Offset(0, dy), Offset(size.width, dy), gridPaint); + } + + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _DailyRhythmPainter oldDelegate) { + return oldDelegate.progress != progress || + oldDelegate.accent != accent || + oldDelegate.baseColor != baseColor; + } +} + +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, + ); + } +}