From caf8d138fc24248e9b80a3a1bd798b147899f997 Mon Sep 17 00:00:00 2001 From: dylan-kramer Date: Thu, 13 Nov 2025 22:53:32 -0600 Subject: [PATCH 1/4] Add customizable settings page with theme and commitment stats --- .gitignore | 1 + lib/app/app.dart | 34 +- lib/app/router.dart | 7 +- .../app_profile/app_profile_controller.dart | 73 +++ lib/core/data/app_profile_repository.dart | 40 ++ lib/core/db/app_profile_record.dart | 12 + lib/core/db/isar_db.dart | 4 +- lib/core/theme/app_theme.dart | 471 ++++++++++++------ .../controller/contract_controller.dart | 7 +- .../repositories/contract_repository.dart | 16 +- .../home/controller/home_controller.dart | 9 +- lib/features/home/view/home_page.dart | 11 +- lib/features/settings/view/settings_page.dart | 334 +++++++++++++ lib/main.dart | 5 +- 14 files changed, 847 insertions(+), 177 deletions(-) create mode 100644 lib/core/app_profile/app_profile_controller.dart create mode 100644 lib/core/data/app_profile_repository.dart create mode 100644 lib/core/db/app_profile_record.dart create mode 100644 lib/features/settings/view/settings_page.dart diff --git a/.gitignore b/.gitignore index 428bfb9..40640c3 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ ios/Podfile.lock linux/flutter/generated_plugin_registrant.cc lib/features/contract/data/contract_record.g.dart lib/features/contract/data/reminder_record.g.dart +lib/core/db/app_profile_record.g.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index a3dfff0..c836a31 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,3 +1,4 @@ +import 'package:du/core/app_profile/app_profile_controller.dart'; import 'package:du/core/theme/app_theme.dart'; import 'package:flutter/material.dart'; import 'router.dart'; @@ -7,11 +8,34 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp.router( - title: 'Du', - routerConfig: appRouter, - theme: appDarkTheme, - debugShowCheckedModeBanner: false, + final controller = AppProfileController.instance; + + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + if (controller.isLoading) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + home: const Scaffold( + body: Center(child: CircularProgressIndicator()), + ), + ); + } + + final accent = controller.accentColor; + final themeMode = controller.darkMode ? ThemeMode.dark : ThemeMode.light; + + return MaterialApp.router( + title: 'Du', + routerConfig: appRouter, + theme: AppTheme.light(accent: accent), + darkTheme: AppTheme.dark(accent: accent), + themeMode: themeMode, + debugShowCheckedModeBanner: false, + ); + }, ); } } diff --git a/lib/app/router.dart b/lib/app/router.dart index 65259fa..1a13461 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'; +import 'package:go_router/go_router.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/app_profile/app_profile_controller.dart b/lib/core/app_profile/app_profile_controller.dart new file mode 100644 index 0000000..ac0e5ab --- /dev/null +++ b/lib/core/app_profile/app_profile_controller.dart @@ -0,0 +1,73 @@ +import 'dart:ui'; + +import 'package:du/core/data/app_profile_repository.dart'; +import 'package:du/core/db/app_profile_record.dart'; +import 'package:du/core/theme/app_theme.dart'; +import 'package:flutter/foundation.dart'; + +class AppProfileController extends ChangeNotifier { + AppProfileController._(); + + static final AppProfileController instance = AppProfileController._(); + + final AppProfileRepository _repository = AppProfileRepository(); + + AppProfileRecord? _profile; + bool _loading = true; + + bool get isLoading => _loading; + bool get darkMode => _profile?.darkMode ?? true; + Color get accentColor => Color(_profile?.accentColor ?? AppTheme.defaultAccentValue); + int get successCount => _profile?.successCount ?? 0; + int get failCount => _profile?.failCount ?? 0; + int get totalResolved => successCount + failCount; + + double get commitmentRatio { + final total = totalResolved; + if (total == 0) return 0; + return successCount / total; + } + + Future init() async { + if (!_loading) return; + _profile = await _repository.ensure(); + _loading = false; + notifyListeners(); + } + + Future setDarkMode(bool value) async { + if (_profile == null) return; + if (_profile!.darkMode == value) return; + _profile! + ..darkMode = value; + await _repository.save(_profile!); + notifyListeners(); + } + + Future setAccentColor(Color color) async { + if (_profile == null) return; + if (_profile!.accentColor == color.value) return; + _profile! + ..accentColor = color.value; + await _repository.save(_profile!); + notifyListeners(); + } + + Future recordSuccess() async { + if (_profile == null) return; + _profile! + ..successCount += 1; + await _repository.save(_profile!); + notifyListeners(); + } + + Future recordFailure() async { + if (_profile == null) return; + _profile! + ..failCount += 1; + await _repository.save(_profile!); + notifyListeners(); + } + + List get accentOptions => List.unmodifiable(AppTheme.accentOptions); +} diff --git a/lib/core/data/app_profile_repository.dart b/lib/core/data/app_profile_repository.dart new file mode 100644 index 0000000..9ee9c30 --- /dev/null +++ b/lib/core/data/app_profile_repository.dart @@ -0,0 +1,40 @@ +import 'package:du/core/db/app_profile_record.dart'; +import 'package:du/core/db/isar_db.dart'; +import 'package:du/features/contract/data/contract_record.dart'; +import 'package:isar/isar.dart'; + +class AppProfileRepository { + Future get _db => AppDb.instance(); + + Future ensure() async { + final isar = await _db; + final existing = await isar.appProfileRecords.get(0); + if (existing != null) return existing; + + final successes = await isar.contractRecords + .filter() + .succeededEqualTo(true) + .count(); + final failures = await isar.contractRecords + .filter() + .succeededEqualTo(false) + .count(); + + final record = AppProfileRecord() + ..successCount = successes + ..failCount = failures; + + await isar.writeTxn(() async { + await isar.appProfileRecords.put(record); + }); + return record; + } + + Future save(AppProfileRecord record) async { + final isar = await _db; + await isar.writeTxn(() async { + await isar.appProfileRecords.put(record); + }); + return record; + } +} diff --git a/lib/core/db/app_profile_record.dart b/lib/core/db/app_profile_record.dart new file mode 100644 index 0000000..f5a71e1 --- /dev/null +++ b/lib/core/db/app_profile_record.dart @@ -0,0 +1,12 @@ +import 'package:isar/isar.dart'; + +part 'app_profile_record.g.dart'; + +@collection +class AppProfileRecord { + Id id = 0; + bool darkMode = true; + int accentColor = 0xFF3A7AFE; + int successCount = 0; + int failCount = 0; +} diff --git a/lib/core/db/isar_db.dart b/lib/core/db/isar_db.dart index bf6ee20..1835a45 100644 --- a/lib/core/db/isar_db.dart +++ b/lib/core/db/isar_db.dart @@ -1,5 +1,6 @@ import 'package:isar/isar.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:du/core/db/app_profile_record.dart'; import 'package:du/features/contract/data/contract_record.dart'; import 'package:du/features/contract/data/reminder_record.dart'; @@ -12,7 +13,8 @@ class AppDb { _instance = await Isar.open( [ ContractRecordSchema, - ReminderRecordSchema + ReminderRecordSchema, + AppProfileRecordSchema, ], directory: dir.path, inspector: !bool.fromEnvironment('dart.vm.product'), diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 6971d27..14a12e5 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -1,169 +1,326 @@ import 'package:flutter/material.dart'; -// === Palette (constants) === -const _bg = Color(0xFF0E0E10); -const _surface = Color(0xFF16171A); -const _surfaceV = Color(0xFF1F2024); -const _outline = Color(0xFF2A2B2F); +class AppTheme { + static const int defaultAccentValue = 0xFF3A7AFE; + static const Color defaultAccent = Color(defaultAccentValue); -const _primary = Color(0xFF3A7AFE); -const _primaryPr = Color(0xFF2E63CC); -const _secondary = Color(0xFF4ED1A1); -const _error = Color(0xFFD34A4A); -const _textHi = Color(0xFFFFFFFF); -const _textMed = Color(0xFFC5C7CA); -const _textLo = Color(0xFF8A8D93); + static const Color _secondary = Color(0xFF4ED1A1); + static const Color _error = Color(0xFFD34A4A); -// === Single Dark ThemeData === -final ThemeData appDarkTheme = ThemeData( - useMaterial3: true, - brightness: Brightness.dark, - scaffoldBackgroundColor: _bg, - colorScheme: const ColorScheme( - brightness: Brightness.dark, - primary: _primary, - onPrimary: _textHi, - secondary: _secondary, - onSecondary: Colors.black, - surface: _surface, - onSurface: _textMed, - surfaceContainerHighest: _surfaceV, - onSurfaceVariant: _textLo, - error: _error, - onError: _textHi, - outline: _outline, - ), + static const Color _darkBg = Color(0xFF0E0E10); + static const Color _darkSurface = Color(0xFF16171A); + static const Color _darkSurfaceV = Color(0xFF1F2024); + static const Color _darkOutline = Color(0xFF2A2B2F); + static const Color _darkTextMed = Color(0xFFC5C7CA); + static const Color _darkTextLo = Color(0xFF8A8D93); - // Typography - textTheme: Typography.whiteMountainView.apply( - bodyColor: _textMed, - displayColor: _textMed, - ), + static const Color _lightBg = Color(0xFFF4F5F9); + static const Color _lightSurface = Color(0xFFFFFFFF); + static const Color _lightSurfaceV = Color(0xFFF0F2F7); + static const Color _lightOutline = Color(0xFFD8DCE6); + static const Color _lightTextMed = Color(0xFF1F2230); + static const Color _lightTextLo = Color(0xFF6B7285); - // AppBar - appBarTheme: const AppBarTheme( - backgroundColor: _surface, - foregroundColor: _textMed, - elevation: 0, - centerTitle: false, - titleTextStyle: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), - ), + static const List _accentChoices = [ + Color(0xFF3A7AFE), + Color(0xFF7C5CFF), + Color(0xFF4ED1A1), + Color(0xFFFF7A7A), + Color(0xFFFFB347), + ]; - // Cards - cardTheme: CardThemeData( - color: _surface, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: const BorderSide(color: _outline), - ), - margin: EdgeInsets.zero, - ), + static List get accentOptions => _accentChoices; - // Buttons - elevatedButtonTheme: ElevatedButtonThemeData( - style: ButtonStyle( - minimumSize: const WidgetStatePropertyAll(Size(40, 40)), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) return _primaryPr; - return _primary; - }), - foregroundColor: const WidgetStatePropertyAll(_textHi), - ), - ), + static ThemeData dark({Color accent = defaultAccent}) { + final accentPressed = _darken(accent, 0.18); - outlinedButtonTheme: OutlinedButtonThemeData( - style: ButtonStyle( - side: const WidgetStatePropertyAll(BorderSide(color: _outline)), - foregroundColor: const WidgetStatePropertyAll(_textMed), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ), - ), - - textButtonTheme: TextButtonThemeData( - style: ButtonStyle( - foregroundColor: const WidgetStatePropertyAll(_primary), - overlayColor: WidgetStatePropertyAll(_primary.withValues(alpha: 0.08)), - ), - ), - - - // Chips - chipTheme: const ChipThemeData( - backgroundColor: _surfaceV, - selectedColor: Color(0x2A3A7AFE), // primary with low opacity - disabledColor: Color(0x66222428), - labelStyle: TextStyle(color: _textMed), - secondaryLabelStyle: TextStyle(color: _textMed), - side: BorderSide(color: _outline), - shape: StadiumBorder(), - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2), - ), - - // Text fields - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: _surface, - hintStyle: const TextStyle(color: _textLo), - labelStyle: const TextStyle(color: _textMed), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: _outline), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: _outline), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: _primary), - ), - ), + return ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: _darkBg, + colorScheme: ColorScheme( + brightness: Brightness.dark, + primary: accent, + onPrimary: Colors.white, + secondary: _secondary, + onSecondary: Colors.black, + surface: _darkSurface, + onSurface: _darkTextMed, + surfaceContainerHighest: _darkSurfaceV, + onSurfaceVariant: _darkTextLo, + error: _error, + onError: Colors.white, + outline: _darkOutline, + ), + textTheme: Typography.whiteMountainView.apply( + bodyColor: _darkTextMed, + displayColor: _darkTextMed, + ), + appBarTheme: const AppBarTheme( + backgroundColor: _darkSurface, + foregroundColor: _darkTextMed, + elevation: 0, + centerTitle: false, + titleTextStyle: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + cardTheme: CardThemeData( + color: _darkSurface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: _darkOutline), + ), + margin: EdgeInsets.zero, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ButtonStyle( + minimumSize: const WidgetStatePropertyAll(Size(40, 40)), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) return accentPressed; + return accent; + }), + foregroundColor: const WidgetStatePropertyAll(Colors.white), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: ButtonStyle( + side: const WidgetStatePropertyAll(BorderSide(color: _darkOutline)), + foregroundColor: const WidgetStatePropertyAll(_darkTextMed), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: ButtonStyle( + foregroundColor: WidgetStatePropertyAll(accent), + overlayColor: WidgetStatePropertyAll(accent.withValues(alpha: 0.08)), + ), + ), + chipTheme: ChipThemeData( + backgroundColor: _darkSurfaceV, + selectedColor: accent.withValues(alpha: 0.22), + disabledColor: const Color(0x66222428), + labelStyle: const TextStyle(color: _darkTextMed), + secondaryLabelStyle: const TextStyle(color: _darkTextMed), + side: const BorderSide(color: _darkOutline), + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: _darkSurface, + hintStyle: const TextStyle(color: _darkTextLo), + labelStyle: const TextStyle(color: _darkTextMed), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _darkOutline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _darkOutline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: accent), + ), + ), + dividerTheme: const DividerThemeData( + color: _darkOutline, + space: 1, + thickness: 1, + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: _darkSurface, + contentTextStyle: const TextStyle(color: Colors.white), + behavior: SnackBarBehavior.floating, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: const BorderSide(color: _darkOutline), + ), + ), + iconTheme: const IconThemeData(color: _darkTextLo), + tooltipTheme: TooltipThemeData( + decoration: BoxDecoration( + color: _darkSurfaceV, + borderRadius: BorderRadius.circular(8), + border: const Border.fromBorderSide(BorderSide(color: _darkOutline)), + ), + textStyle: const TextStyle(color: _darkTextMed), + ), + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: _darkSurface, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + side: const BorderSide(color: _darkOutline), + ), + ), + dialogTheme: DialogThemeData( + backgroundColor: _darkSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: _darkOutline), + ), + titleTextStyle: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: _darkTextMed, + ), + ), + ); + } - dividerTheme: const DividerThemeData( - color: _outline, - space: 1, - thickness: 1, - ), + static ThemeData light({Color accent = defaultAccent}) { + final accentPressed = _darken(accent, 0.16); - snackBarTheme: SnackBarThemeData( - backgroundColor: _surface, - contentTextStyle: const TextStyle(color: _textHi), - behavior: SnackBarBehavior.floating, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: const BorderSide(color: _outline), - ), - ), + return ThemeData( + useMaterial3: true, + brightness: Brightness.light, + scaffoldBackgroundColor: _lightBg, + colorScheme: ColorScheme( + brightness: Brightness.light, + primary: accent, + onPrimary: Colors.white, + secondary: _secondary, + onSecondary: Colors.white, + surface: _lightSurface, + onSurface: _lightTextMed, + surfaceContainerHighest: _lightSurfaceV, + onSurfaceVariant: _lightTextLo, + error: _error, + onError: Colors.white, + outline: _lightOutline, + ), + textTheme: Typography.blackMountainView.apply( + bodyColor: _lightTextMed, + displayColor: _lightTextMed, + ), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + foregroundColor: _lightTextMed, + elevation: 0, + centerTitle: false, + titleTextStyle: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + cardTheme: CardThemeData( + color: _lightSurface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: _lightOutline), + ), + margin: EdgeInsets.zero, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ButtonStyle( + minimumSize: const WidgetStatePropertyAll(Size(40, 40)), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) return accentPressed; + return accent; + }), + foregroundColor: const WidgetStatePropertyAll(Colors.white), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: ButtonStyle( + side: const WidgetStatePropertyAll(BorderSide(color: _lightOutline)), + foregroundColor: const WidgetStatePropertyAll(_lightTextMed), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: ButtonStyle( + foregroundColor: WidgetStatePropertyAll(accent), + overlayColor: WidgetStatePropertyAll(accent.withValues(alpha: 0.12)), + ), + ), + chipTheme: ChipThemeData( + backgroundColor: _lightSurfaceV, + selectedColor: accent.withValues(alpha: 0.18), + disabledColor: _lightOutline.withValues(alpha: 0.6), + labelStyle: const TextStyle(color: _lightTextMed), + secondaryLabelStyle: const TextStyle(color: _lightTextMed), + side: const BorderSide(color: _lightOutline), + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: _lightSurface, + hintStyle: const TextStyle(color: _lightTextLo), + labelStyle: const TextStyle(color: _lightTextMed), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _lightOutline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _lightOutline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: accent), + ), + ), + dividerTheme: const DividerThemeData( + color: _lightOutline, + space: 1, + thickness: 1, + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: _lightSurface, + contentTextStyle: const TextStyle(color: _lightTextMed), + behavior: SnackBarBehavior.floating, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: const BorderSide(color: _lightOutline), + ), + ), + iconTheme: const IconThemeData(color: _lightTextLo), + tooltipTheme: TooltipThemeData( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: const Border.fromBorderSide(BorderSide(color: _lightOutline)), + ), + textStyle: const TextStyle(color: _lightTextMed), + ), + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: _lightSurface, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + side: const BorderSide(color: _lightOutline), + ), + ), + dialogTheme: DialogThemeData( + backgroundColor: _lightSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: _lightOutline), + ), + titleTextStyle: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: _lightTextMed, + ), + ), + ); + } +} - iconTheme: const IconThemeData(color: _textLo), - tooltipTheme: TooltipThemeData( - decoration: BoxDecoration( - color: _surfaceV, - borderRadius: BorderRadius.circular(8), - border: const Border.fromBorderSide(BorderSide(color: _outline)), - ), - textStyle: const TextStyle(color: _textMed), - ), - bottomSheetTheme: BottomSheetThemeData( - backgroundColor: _surface, - shape: RoundedRectangleBorder( - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - side: const BorderSide(color: _outline), - ), - ), - dialogTheme: DialogThemeData( - backgroundColor: _surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: const BorderSide(color: _outline), - ), - titleTextStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: _textMed), - ), -); +Color _darken(Color color, [double amount = .1]) { + final hsl = HSLColor.fromColor(color); + final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0)); + return hslDark.toColor(); +} diff --git a/lib/features/contract/controller/contract_controller.dart b/lib/features/contract/controller/contract_controller.dart index b68e750..baa20de 100644 --- a/lib/features/contract/controller/contract_controller.dart +++ b/lib/features/contract/controller/contract_controller.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:du/features/contract/data/contract_record.dart'; import 'package:flutter/material.dart'; +import 'package:du/core/app_profile/app_profile_controller.dart'; import 'package:du/core/notifications/notif_service.dart'; import 'package:du/features/contract/data/repositories/contract_repository.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -102,12 +103,14 @@ class ContractController { Future succeed(int id) async { await _cancelAllForContract(id); - await _repo.succeed(id); + final changed = await _repo.succeed(id); + if (changed) await AppProfileController.instance.recordSuccess(); } Future fail(int id) async { await _cancelAllForContract(id); - await _repo.fail(id); + final changed = await _repo.fail(id); + if (changed) await AppProfileController.instance.recordFailure(); } Future delete(int id) async { diff --git a/lib/features/contract/data/repositories/contract_repository.dart b/lib/features/contract/data/repositories/contract_repository.dart index f945d8a..3b4e2d6 100644 --- a/lib/features/contract/data/repositories/contract_repository.dart +++ b/lib/features/contract/data/repositories/contract_repository.dart @@ -131,23 +131,27 @@ class ContractRepository { .toList(); } - Future fail(int id) async { + Future fail(int id) async { final isar = await _db; - await isar.writeTxn(() async { + return isar.writeTxn(() async { final record = await isar.contractRecords.get(id); - if (record == null) return; + if (record == null) return false; + if (record.succeeded == false) return false; record.succeeded = false; await isar.contractRecords.put(record); + return true; }); } - Future succeed(int id) async { + Future succeed(int id) async { final isar = await _db; - await isar.writeTxn(() async { + return isar.writeTxn(() async { final record = await isar.contractRecords.get(id); - if (record == null) return; + if (record == null) return false; + if (record.succeeded == true) return false; record.succeeded = true; await isar.contractRecords.put(record); + return true; }); } diff --git a/lib/features/home/controller/home_controller.dart b/lib/features/home/controller/home_controller.dart index 46ff0a9..6313d75 100644 --- a/lib/features/home/controller/home_controller.dart +++ b/lib/features/home/controller/home_controller.dart @@ -1,10 +1,11 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; +import 'package:du/core/app_profile/app_profile_controller.dart'; import 'package:du/core/notifications/notif_service.dart'; import 'package:du/features/contract/data/reminder_record.dart'; import 'package:du/features/contract/data/repositories/contract_repository.dart'; import 'package:du/features/home/model/home_state.dart'; +import 'package:flutter/foundation.dart'; class HomeController { final ContractRepository _repo = ContractRepository(); @@ -75,13 +76,15 @@ class HomeController { Future fail(int id) async { await _cancelAllForContract(id); - await _repo.fail(id); + final changed = await _repo.fail(id); + if (changed) await AppProfileController.instance.recordFailure(); await load(); } Future succeed(int id) async { await _cancelAllForContract(id); - await _repo.succeed(id); + final changed = await _repo.succeed(id); + if (changed) await AppProfileController.instance.recordSuccess(); await load(); } diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart index 4f3afe7..6bc81c2 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: 'Settings', + icon: const Icon(Icons.settings_outlined), + onPressed: () => context.push('/settings'), + ), + ], + ), body: Column( children: [ Padding( diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart new file mode 100644 index 0000000..dd785a1 --- /dev/null +++ b/lib/features/settings/view/settings_page.dart @@ -0,0 +1,334 @@ +import 'package:du/core/app_profile/app_profile_controller.dart'; +import 'package:flutter/material.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + String _commitmentHeadline(int successes, int failures) { + final total = successes + failures; + if (total == 0) return "Let's build your streak"; + final ratio = successes / total; + if (successes == total && total > 0) return 'Unstoppable momentum'; + if (ratio >= 0.75) return 'You are dialed in'; + if (ratio >= 0.5) return 'Progress over perfection'; + if (total < 5) return 'Early reps matter'; + return 'Time to bounce back'; + } + + String _commitmentMessage(int successes, int failures) { + final total = successes + failures; + if (total == 0) { + return 'Every contract you commit to will grow this bar. Keep showing up.'; + } + + final ratio = successes / total; + if (ratio >= 0.75) { + return 'Your follow-through is paying off. Keep stacking those wins.'; + } + if (ratio >= 0.5) { + return 'Consistency beats intensity. Stay steady and the momentum builds.'; + } + return 'Setbacks happen. Use them as data, refine the plan, and keep moving.'; + } + + @override + Widget build(BuildContext context) { + final controller = AppProfileController.instance; + + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + if (controller.isLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final accent = controller.accentColor; + final successes = controller.successCount; + final failures = controller.failCount; + final total = controller.totalResolved; + final ratio = controller.commitmentRatio; + final scoreText = total == 0 ? '0%' : '${(ratio * 100).toStringAsFixed(0)}%'; + + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Appearance', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 12), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Dark mode'), + subtitle: const Text('Lean into the night shift vibes or brighten things up.'), + value: controller.darkMode, + onChanged: (value) => controller.setDarkMode(value), + ), + const SizedBox(height: 16), + Text( + 'Accent color', + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final option in controller.accentOptions) + _AccentChoice( + color: option, + selected: option.value == controller.accentColor.value, + onTap: () => controller.setAccentColor(option), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Pick a hue that keeps you energized. Changes apply instantly.', + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Commitment tracker', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Text( + 'All-time successes and setbacks, including contracts you have already deleted.', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 16), + _CommitmentMeter( + accent: accent, + ratio: ratio, + scoreText: scoreText, + ), + const SizedBox(height: 16), + Text( + _commitmentHeadline(successes, failures), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: accent, + ), + ), + const SizedBox(height: 8), + Text( + _commitmentMessage(successes, failures), + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _StatBadge( + label: 'Succeeded', + value: successes, + color: colorScheme.secondary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _StatBadge( + label: 'Failed', + value: failures, + color: colorScheme.error, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _StatBadge( + label: 'Total logged', + value: total, + color: accent, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _CommitmentMeter extends StatelessWidget { + const _CommitmentMeter({ + required this.accent, + required this.ratio, + required this.scoreText, + }); + + final Color accent; + final double ratio; + final String scoreText; + + @override + Widget build(BuildContext context) { + final background = Theme.of(context).colorScheme.surfaceContainerHighest; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + height: 16, + child: LinearProgressIndicator( + value: ratio, + minHeight: 16, + backgroundColor: background, + valueColor: AlwaysStoppedAnimation(accent), + ), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Commitment score', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + scoreText, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + color: accent, + ), + ), + ], + ), + ], + ); + } +} + +class _StatBadge extends StatelessWidget { + const _StatBadge({ + required this.label, + required this.value, + required this.color, + }); + + final String label; + final int value; + final Color color; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final borderColor = color.withValues(alpha: 0.4); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + color: color.withValues(alpha: 0.08), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.labelMedium), + const SizedBox(height: 4), + Text( + '$value', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + color: color, + fontSize: 24, + ), + ), + ], + ), + ); + } +} + +class _AccentChoice extends StatelessWidget { + const _AccentChoice({ + required this.color, + required this.selected, + required this.onTap, + }); + + final Color color; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final checkColor = ThemeData.estimateBrightnessForColor(color) == Brightness.dark + ? Colors.white + : Colors.black.withOpacity(0.75); + + return Material( + color: Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + border: Border.all( + color: selected ? color.withValues(alpha: 0.9) : color.withValues(alpha: 0.4), + width: selected ? 3 : 1.5, + ), + boxShadow: selected + ? [ + BoxShadow( + color: color.withValues(alpha: 0.35), + blurRadius: 18, + spreadRadius: 1, + ), + ] + : null, + ), + child: selected + ? Icon( + Icons.check, + color: checkColor, + ) + : null, + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 24a91c6..0d6e188 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,11 @@ +import 'package:du/app/app.dart'; +import 'package:du/core/app_profile/app_profile_controller.dart'; import 'package:flutter/material.dart'; import 'package:timezone/data/latest.dart' as tz; -import 'package:du/app/app.dart'; Future main() async { + WidgetsFlutterBinding.ensureInitialized(); tz.initializeTimeZones(); + await AppProfileController.instance.init(); runApp(const MyApp()); } From 3ee9b35a61ace5eee974f463773b306492adc801 Mon Sep 17 00:00:00 2001 From: dylan-kramer Date: Thu, 13 Nov 2025 23:04:42 -0600 Subject: [PATCH 2/4] Simplify settings and emphasize accent-driven theming --- .../app_profile/app_profile_controller.dart | 51 +- lib/core/data/app_profile_repository.dart | 28 +- lib/core/db/app_profile_record.dart | 1 + lib/core/theme/app_theme.dart | 155 ++++- lib/features/home/view/home_page.dart | 5 +- lib/features/settings/view/settings_page.dart | 593 +++++++++++------- 6 files changed, 548 insertions(+), 285 deletions(-) diff --git a/lib/core/app_profile/app_profile_controller.dart b/lib/core/app_profile/app_profile_controller.dart index ac0e5ab..2ab1656 100644 --- a/lib/core/app_profile/app_profile_controller.dart +++ b/lib/core/app_profile/app_profile_controller.dart @@ -10,6 +10,8 @@ class AppProfileController extends ChangeNotifier { static final AppProfileController instance = AppProfileController._(); + static const int _maxTrendPoints = 30; + final AppProfileRepository _repository = AppProfileRepository(); AppProfileRecord? _profile; @@ -21,6 +23,8 @@ class AppProfileController extends ChangeNotifier { int get successCount => _profile?.successCount ?? 0; int get failCount => _profile?.failCount ?? 0; int get totalResolved => successCount + failCount; + List get commitmentTrend => + List.unmodifiable(_profile?.commitmentTrend ?? const []); double get commitmentRatio { final total = totalResolved; @@ -28,9 +32,32 @@ class AppProfileController extends ChangeNotifier { return successCount / total; } + Future _persist({bool trackMomentum = false}) async { + if (_profile == null) return; + + if (trackMomentum) { + final total = totalResolved; + if (total > 0) { + final updated = List.from(_profile!.commitmentTrend) + ..add(successCount / total); + if (updated.length > _maxTrendPoints) { + updated.removeRange(0, updated.length - _maxTrendPoints); + } + _profile!.commitmentTrend = updated; + } + } + + await _repository.save(_profile!); + notifyListeners(); + } + Future init() async { if (!_loading) return; _profile = await _repository.ensure(); + if (_profile != null && _profile!.commitmentTrend.isEmpty && totalResolved > 0) { + _profile!.commitmentTrend = [commitmentRatio]; + await _repository.save(_profile!); + } _loading = false; notifyListeners(); } @@ -38,35 +65,27 @@ class AppProfileController extends ChangeNotifier { Future setDarkMode(bool value) async { if (_profile == null) return; if (_profile!.darkMode == value) return; - _profile! - ..darkMode = value; - await _repository.save(_profile!); - notifyListeners(); + _profile!.darkMode = value; + await _persist(); } Future setAccentColor(Color color) async { if (_profile == null) return; if (_profile!.accentColor == color.value) return; - _profile! - ..accentColor = color.value; - await _repository.save(_profile!); - notifyListeners(); + _profile!.accentColor = color.value; + await _persist(); } Future recordSuccess() async { if (_profile == null) return; - _profile! - ..successCount += 1; - await _repository.save(_profile!); - notifyListeners(); + _profile!.successCount += 1; + await _persist(trackMomentum: true); } Future recordFailure() async { if (_profile == null) return; - _profile! - ..failCount += 1; - await _repository.save(_profile!); - notifyListeners(); + _profile!.failCount += 1; + await _persist(trackMomentum: true); } List get accentOptions => List.unmodifiable(AppTheme.accentOptions); diff --git a/lib/core/data/app_profile_repository.dart b/lib/core/data/app_profile_repository.dart index 9ee9c30..130896b 100644 --- a/lib/core/data/app_profile_repository.dart +++ b/lib/core/data/app_profile_repository.dart @@ -20,9 +20,35 @@ class AppProfileRepository { .succeededEqualTo(false) .count(); + final resolved = await isar.contractRecords + .filter() + .succeededIsNotNull() + .sortByCreatedAt() + .findAll(); + + final trend = []; + var wins = 0; + var total = 0; + for (final record in resolved) { + total += 1; + if (record.succeeded == true) wins += 1; + trend.add(wins / total); + } + + final totalOutcomes = successes + failures; + if (trend.isEmpty && totalOutcomes > 0) { + trend.add(successes / totalOutcomes); + } + + // keep the seeded history intentionally short to avoid bloating the profile record + const seedLimit = 30; + final clippedTrend = + trend.length > seedLimit ? trend.sublist(trend.length - seedLimit) : trend; + final record = AppProfileRecord() ..successCount = successes - ..failCount = failures; + ..failCount = failures + ..commitmentTrend = clippedTrend; await isar.writeTxn(() async { await isar.appProfileRecords.put(record); diff --git a/lib/core/db/app_profile_record.dart b/lib/core/db/app_profile_record.dart index f5a71e1..b2387e8 100644 --- a/lib/core/db/app_profile_record.dart +++ b/lib/core/db/app_profile_record.dart @@ -9,4 +9,5 @@ class AppProfileRecord { int accentColor = 0xFF3A7AFE; int successCount = 0; int failCount = 0; + List commitmentTrend = []; } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 14a12e5..4a50629 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -4,7 +4,6 @@ class AppTheme { static const int defaultAccentValue = 0xFF3A7AFE; static const Color defaultAccent = Color(defaultAccentValue); - static const Color _secondary = Color(0xFF4ED1A1); static const Color _error = Color(0xFFD34A4A); static const Color _darkBg = Color(0xFF0E0E10); @@ -34,24 +33,26 @@ class AppTheme { static ThemeData dark({Color accent = defaultAccent}) { final accentPressed = _darken(accent, 0.18); + final scheme = ColorScheme( + brightness: Brightness.dark, + primary: accent, + onPrimary: Colors.white, + secondary: accent, + onSecondary: Colors.white, + surface: _darkSurface, + onSurface: _darkTextMed, + surfaceContainerHighest: _darkSurfaceV, + onSurfaceVariant: _darkTextLo, + error: _error, + onError: Colors.white, + outline: _darkOutline, + ); + return ThemeData( useMaterial3: true, brightness: Brightness.dark, scaffoldBackgroundColor: _darkBg, - colorScheme: ColorScheme( - brightness: Brightness.dark, - primary: accent, - onPrimary: Colors.white, - secondary: _secondary, - onSecondary: Colors.black, - surface: _darkSurface, - onSurface: _darkTextMed, - surfaceContainerHighest: _darkSurfaceV, - onSurfaceVariant: _darkTextLo, - error: _error, - onError: Colors.white, - outline: _darkOutline, - ), + colorScheme: scheme, textTheme: Typography.whiteMountainView.apply( bodyColor: _darkTextMed, displayColor: _darkTextMed, @@ -102,7 +103,7 @@ class AppTheme { ), chipTheme: ChipThemeData( backgroundColor: _darkSurfaceV, - selectedColor: accent.withValues(alpha: 0.22), + selectedColor: accent.withValues(alpha: 0.28), disabledColor: const Color(0x66222428), labelStyle: const TextStyle(color: _darkTextMed), secondaryLabelStyle: const TextStyle(color: _darkTextMed), @@ -143,7 +144,7 @@ class AppTheme { side: const BorderSide(color: _darkOutline), ), ), - iconTheme: const IconThemeData(color: _darkTextLo), + iconTheme: IconThemeData(color: accent.withValues(alpha: 0.8)), tooltipTheme: TooltipThemeData( decoration: BoxDecoration( color: _darkSurfaceV, @@ -171,30 +172,75 @@ class AppTheme { color: _darkTextMed, ), ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return _darkOutline.withValues(alpha: 0.5); + } + return accent; + }), + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return _darkOutline.withValues(alpha: 0.3); + } + if (states.contains(WidgetState.selected)) { + return accent.withValues(alpha: 0.45); + } + return _darkOutline.withValues(alpha: 0.35); + }), + ), + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return _darkOutline.withValues(alpha: 0.4); + } + return accent; + }), + checkColor: const WidgetStatePropertyAll(Colors.white), + ), + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return _darkOutline.withValues(alpha: 0.4); + } + return accent; + }), + ), + listTileTheme: ListTileThemeData( + iconColor: scheme.onSurfaceVariant, + selectedColor: accent, + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: accent, + foregroundColor: Colors.white, + ), + progressIndicatorTheme: ProgressIndicatorThemeData(color: accent), ); } static ThemeData light({Color accent = defaultAccent}) { final accentPressed = _darken(accent, 0.16); + final scheme = ColorScheme( + brightness: Brightness.light, + primary: accent, + onPrimary: Colors.white, + secondary: accent, + onSecondary: Colors.white, + surface: _lightSurface, + onSurface: _lightTextMed, + surfaceContainerHighest: _lightSurfaceV, + onSurfaceVariant: _lightTextLo, + error: _error, + onError: Colors.white, + outline: _lightOutline, + ); + return ThemeData( useMaterial3: true, brightness: Brightness.light, scaffoldBackgroundColor: _lightBg, - colorScheme: ColorScheme( - brightness: Brightness.light, - primary: accent, - onPrimary: Colors.white, - secondary: _secondary, - onSecondary: Colors.white, - surface: _lightSurface, - onSurface: _lightTextMed, - surfaceContainerHighest: _lightSurfaceV, - onSurfaceVariant: _lightTextLo, - error: _error, - onError: Colors.white, - outline: _lightOutline, - ), + colorScheme: scheme, textTheme: Typography.blackMountainView.apply( bodyColor: _lightTextMed, displayColor: _lightTextMed, @@ -246,7 +292,7 @@ class AppTheme { ), chipTheme: ChipThemeData( backgroundColor: _lightSurfaceV, - selectedColor: accent.withValues(alpha: 0.18), + selectedColor: accent.withValues(alpha: 0.24), disabledColor: _lightOutline.withValues(alpha: 0.6), labelStyle: const TextStyle(color: _lightTextMed), secondaryLabelStyle: const TextStyle(color: _lightTextMed), @@ -287,7 +333,7 @@ class AppTheme { side: const BorderSide(color: _lightOutline), ), ), - iconTheme: const IconThemeData(color: _lightTextLo), + iconTheme: IconThemeData(color: accent.withValues(alpha: 0.8)), tooltipTheme: TooltipThemeData( decoration: BoxDecoration( color: Colors.white, @@ -315,6 +361,49 @@ class AppTheme { color: _lightTextMed, ), ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return _lightOutline.withValues(alpha: 0.5); + } + return accent; + }), + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return _lightOutline.withValues(alpha: 0.2); + } + if (states.contains(WidgetState.selected)) { + return accent.withValues(alpha: 0.45); + } + return _lightOutline.withValues(alpha: 0.3); + }), + ), + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return _lightOutline.withValues(alpha: 0.4); + } + return accent; + }), + checkColor: const WidgetStatePropertyAll(Colors.white), + ), + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return _lightOutline.withValues(alpha: 0.4); + } + return accent; + }), + ), + listTileTheme: ListTileThemeData( + iconColor: scheme.onSurfaceVariant, + selectedColor: accent, + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: accent, + foregroundColor: Colors.white, + ), + progressIndicatorTheme: ProgressIndicatorThemeData(color: accent), ); } } diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart index 6bc81c2..00139e6 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -157,6 +157,7 @@ class _HomePageState extends State { } final items = _itemsForFilter(st); + final accent = Theme.of(context).colorScheme.primary; return Scaffold( appBar: AppBar( @@ -179,14 +180,14 @@ class _HomePageState extends State { StatBox( label: 'Active', count: st.active.length, - color: Colors.blueAccent, + color: accent, selected: _filter == ContractFilter.active, onTap: () => setState(() => _filter = ContractFilter.active), ), StatBox( label: 'Succeeded', count: st.succeededCount, - color: Colors.green, + color: accent.withValues(alpha: 0.85), selected: _filter == ContractFilter.succeeded, onTap: () => setState(() => _filter = ContractFilter.succeeded), ), diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index dd785a1..4a4bcf0 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -4,33 +4,6 @@ import 'package:flutter/material.dart'; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); - String _commitmentHeadline(int successes, int failures) { - final total = successes + failures; - if (total == 0) return "Let's build your streak"; - final ratio = successes / total; - if (successes == total && total > 0) return 'Unstoppable momentum'; - if (ratio >= 0.75) return 'You are dialed in'; - if (ratio >= 0.5) return 'Progress over perfection'; - if (total < 5) return 'Early reps matter'; - return 'Time to bounce back'; - } - - String _commitmentMessage(int successes, int failures) { - final total = successes + failures; - if (total == 0) { - return 'Every contract you commit to will grow this bar. Keep showing up.'; - } - - final ratio = successes / total; - if (ratio >= 0.75) { - return 'Your follow-through is paying off. Keep stacking those wins.'; - } - if (ratio >= 0.5) { - return 'Consistency beats intensity. Stay steady and the momentum builds.'; - } - return 'Setbacks happen. Use them as data, refine the plan, and keep moving.'; - } - @override Widget build(BuildContext context) { final controller = AppProfileController.instance; @@ -45,290 +18,444 @@ class SettingsPage extends StatelessWidget { } final theme = Theme.of(context); - final colorScheme = theme.colorScheme; final accent = controller.accentColor; final successes = controller.successCount; final failures = controller.failCount; final total = controller.totalResolved; final ratio = controller.commitmentRatio; - final scoreText = total == 0 ? '0%' : '${(ratio * 100).toStringAsFixed(0)}%'; + final history = controller.commitmentTrend; return Scaffold( appBar: AppBar( title: const Text('Settings'), ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + _AccentHero( + accent: accent, + successes: successes, + failures: failures, + ratio: ratio, + total: total, + history: history, + ), + const SizedBox(height: 32), + const _SectionLabel('Theme'), + const SizedBox(height: 12), + _ThemeToggle( + darkMode: controller.darkMode, + onChanged: controller.setDarkMode, + ), + const SizedBox(height: 28), + const _SectionLabel('Accent color'), + const SizedBox(height: 12), + _AccentSelector( + selected: accent, + options: controller.accentOptions, + onSelect: controller.setAccentColor, + ), + const SizedBox(height: 8), + Text( + 'This shade paints buttons, chips, and highlights everywhere. Tap to try a new mood.', + style: theme.textTheme.bodySmall, + ), + ], + ), + ); + }, + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Text( + text, + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ); + } +} + +class _ThemeToggle extends StatelessWidget { + const _ThemeToggle({ + required this.darkMode, + required this.onChanged, + }); + + final bool darkMode; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final outline = theme.colorScheme.outline.withOpacity(0.18); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all(color: outline), + color: theme.colorScheme.surface, + ), + child: Row( + children: [ + Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Appearance', - style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 12), - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - title: const Text('Dark mode'), - subtitle: const Text('Lean into the night shift vibes or brighten things up.'), - value: controller.darkMode, - onChanged: (value) => controller.setDarkMode(value), - ), - const SizedBox(height: 16), - Text( - 'Accent color', - style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - for (final option in controller.accentOptions) - _AccentChoice( - color: option, - selected: option.value == controller.accentColor.value, - onTap: () => controller.setAccentColor(option), - ), - ], - ), - const SizedBox(height: 8), - Text( - 'Pick a hue that keeps you energized. Changes apply instantly.', - style: theme.textTheme.bodySmall, - ), - ], - ), - ), + Text( + 'Dark mode', + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), ), - const SizedBox(height: 16), - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Commitment tracker', - style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 6), - Text( - 'All-time successes and setbacks, including contracts you have already deleted.', - style: theme.textTheme.bodySmall, - ), - const SizedBox(height: 16), - _CommitmentMeter( - accent: accent, - ratio: ratio, - scoreText: scoreText, - ), - const SizedBox(height: 16), - Text( - _commitmentHeadline(successes, failures), - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: accent, - ), - ), - const SizedBox(height: 8), - Text( - _commitmentMessage(successes, failures), - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: _StatBadge( - label: 'Succeeded', - value: successes, - color: colorScheme.secondary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _StatBadge( - label: 'Failed', - value: failures, - color: colorScheme.error, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _StatBadge( - label: 'Total logged', - value: total, - color: accent, - ), - ), - ], - ), - ], - ), - ), + const SizedBox(height: 4), + Text( + 'Match the interface to your focus window.', + style: theme.textTheme.bodySmall, ), ], ), ), - ); - }, + Switch.adaptive( + value: darkMode, + onChanged: onChanged, + ), + ], + ), ); } } -class _CommitmentMeter extends StatelessWidget { - const _CommitmentMeter({ +class _AccentHero extends StatelessWidget { + const _AccentHero({ required this.accent, + required this.successes, + required this.failures, required this.ratio, - required this.scoreText, + required this.total, + required this.history, }); final Color accent; + final int successes; + final int failures; final double ratio; - final String scoreText; + final int total; + final List history; @override Widget build(BuildContext context) { - final background = Theme.of(context).colorScheme.surfaceContainerHighest; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: SizedBox( - height: 16, - child: LinearProgressIndicator( - value: ratio, - minHeight: 16, - backgroundColor: background, - valueColor: AlwaysStoppedAnimation(accent), - ), - ), + final theme = Theme.of(context); + final tint = _tint(accent, 0.18); + final ratioText = total == 0 ? '0%' : '${(ratio * 100).round()}%'; + + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [accent, tint], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Commitment score', - style: Theme.of(context).textTheme.bodySmall, + borderRadius: BorderRadius.circular(28), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Momentum', + style: theme.textTheme.labelLarge?.copyWith( + color: Colors.white.withOpacity(0.78), + letterSpacing: 0.4, ), - Text( - scoreText, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w600, - color: accent, + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + ratioText, + style: theme.textTheme.displaySmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + height: 0.9, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + total == 0 + ? 'Log a contract to start the line moving.' + : 'All-time completion rate', + style: theme.textTheme.titleMedium?.copyWith( + color: Colors.white.withOpacity(0.88), ), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + '$successes wins • $failures resets • $total logged', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white.withOpacity(0.9), ), - ], - ), - ], + ), + const SizedBox(height: 20), + SizedBox( + height: 120, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.12), + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: _MomentumGraph( + points: history, + ), + ), + ), + ), + ], + ), ); } } -class _StatBadge extends StatelessWidget { - const _StatBadge({ - required this.label, - required this.value, - required this.color, +class _MomentumGraph extends StatelessWidget { + const _MomentumGraph({ + required this.points, }); - final String label; - final int value; - final Color color; + final List points; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final borderColor = color.withValues(alpha: 0.4); + if (points.isEmpty) { + return Center( + child: Text( + 'Keep logging to build your line.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white.withOpacity(0.85), + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + ); + } - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: borderColor), - color: color.withValues(alpha: 0.08), + return CustomPaint( + painter: _SparklinePainter( + points: points, + lineColor: Colors.white, + fillColor: Colors.white, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: theme.textTheme.labelMedium), - const SizedBox(height: 4), - Text( - '$value', - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - color: color, - fontSize: 24, - ), - ), + ); + } +} + +class _SparklinePainter extends CustomPainter { + _SparklinePainter({ + required this.points, + required this.lineColor, + required this.fillColor, + }); + + final List points; + final Color lineColor; + final Color fillColor; + + @override + void paint(Canvas canvas, Size size) { + if (points.isEmpty) return; + + if (points.length == 1) { + final value = points.first.clamp(0.0, 1.0); + final y = size.height - (value * size.height); + final paint = Paint() + ..color = lineColor + ..style = PaintingStyle.fill + ..isAntiAlias = true; + canvas.drawCircle(Offset(0, y), 4, paint); + return; + } + + final path = Path(); + final dx = size.width / (points.length - 1); + for (var i = 0; i < points.length; i++) { + final x = dx * i; + final value = points[i].clamp(0.0, 1.0); + final y = size.height - (value * size.height); + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + + final fillPath = Path.from(path) + ..lineTo(size.width, size.height) + ..lineTo(0, size.height) + ..close(); + + final fillPaint = Paint() + ..shader = LinearGradient( + colors: [ + fillColor.withOpacity(0.45), + fillColor.withOpacity(0.0), ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..style = PaintingStyle.fill + ..isAntiAlias = true; + + canvas.drawPath(fillPath, fillPaint); + + final strokePaint = Paint() + ..color = lineColor + ..strokeWidth = 3 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..isAntiAlias = true; + + canvas.drawPath(path, strokePaint); + } + + @override + bool shouldRepaint(covariant _SparklinePainter oldDelegate) { + if (oldDelegate.lineColor != lineColor || oldDelegate.fillColor != fillColor) { + return true; + } + if (oldDelegate.points.length != points.length) return true; + for (var i = 0; i < points.length; i++) { + if (oldDelegate.points[i] != points[i]) return true; + } + return false; + } +} + +class _AccentSelector extends StatelessWidget { + const _AccentSelector({ + required this.selected, + required this.options, + required this.onSelect, + }); + + final Color selected; + final List options; + final ValueChanged onSelect; + + static const Map _labels = { + 0xFF3A7AFE: 'Sky', + 0xFF7C5CFF: 'Iris', + 0xFF4ED1A1: 'Mint', + 0xFFFF7A7A: 'Rose', + 0xFFFFB347: 'Amber', + }; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 94, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: options.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final color = options[index]; + final label = _labels[color.value] ?? '#${color.value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}'; + final selectedNow = color.value == selected.value; + return _AccentTile( + color: color, + label: label, + selected: selectedNow, + onTap: () => onSelect(color), + ); + }, ), ); } } -class _AccentChoice extends StatelessWidget { - const _AccentChoice({ +class _AccentTile extends StatelessWidget { + const _AccentTile({ required this.color, + required this.label, required this.selected, required this.onTap, }); final Color color; + final String label; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { - final checkColor = ThemeData.estimateBrightnessForColor(color) == Brightness.dark - ? Colors.white - : Colors.black.withOpacity(0.75); + final brightness = ThemeData.estimateBrightnessForColor(color); + final textColor = brightness == Brightness.dark ? Colors.white : Colors.black87; + final borderColor = brightness == Brightness.dark + ? Colors.white.withOpacity(selected ? 0.9 : 0.4) + : Colors.black.withOpacity(selected ? 0.3 : 0.18); return Material( color: Colors.transparent, - shape: const CircleBorder(), child: InkWell( - customBorder: const CircleBorder(), onTap: onTap, + borderRadius: BorderRadius.circular(18), child: AnimatedContainer( - duration: const Duration(milliseconds: 220), - width: 44, - height: 44, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + width: 112, + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - shape: BoxShape.circle, - color: color, - border: Border.all( - color: selected ? color.withValues(alpha: 0.9) : color.withValues(alpha: 0.4), - width: selected ? 3 : 1.5, - ), + color: selected ? color : color.withOpacity(0.85), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: borderColor, width: selected ? 2 : 1), boxShadow: selected ? [ BoxShadow( - color: color.withValues(alpha: 0.35), - blurRadius: 18, - spreadRadius: 1, + color: color.withOpacity(0.35), + blurRadius: 20, + offset: const Offset(0, 10), ), ] : null, ), - child: selected - ? Icon( - Icons.check, - color: checkColor, - ) - : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + selected ? Icons.check_rounded : Icons.circle_outlined, + size: 18, + color: textColor.withOpacity(selected ? 1 : 0.7), + ), + const Spacer(), + Text( + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: textColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), ), ), ); } } + +Color _tint(Color color, double amount) { + final hsl = HSLColor.fromColor(color); + return hsl.withLightness((hsl.lightness + amount).clamp(0.0, 1.0)).toColor(); +} From 889cb75773048670bdc0751a844550aeb06057d1 Mon Sep 17 00:00:00 2001 From: dylan-kramer Date: Thu, 13 Nov 2025 23:15:23 -0600 Subject: [PATCH 3/4] Simplify accent picker and neutralize task colors --- lib/core/theme/app_theme.dart | 18 +- lib/features/home/view/home_page.dart | 33 +- lib/features/settings/view/settings_page.dart | 471 ++++++++++++------ 3 files changed, 339 insertions(+), 183 deletions(-) diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 4a50629..d294eec 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -30,8 +30,13 @@ class AppTheme { static List get accentOptions => _accentChoices; + static Color tintedSurface(Color surface, Color accent, [double amount = 0.08]) { + return Color.alphaBlend(accent.withOpacity(amount), surface); + } + static ThemeData dark({Color accent = defaultAccent}) { final accentPressed = _darken(accent, 0.18); + final tintedHeader = tintedSurface(_darkSurface, accent, 0.18); final scheme = ColorScheme( brightness: Brightness.dark, @@ -57,12 +62,12 @@ class AppTheme { bodyColor: _darkTextMed, displayColor: _darkTextMed, ), - appBarTheme: const AppBarTheme( - backgroundColor: _darkSurface, + appBarTheme: AppBarTheme( + backgroundColor: tintedHeader, foregroundColor: _darkTextMed, elevation: 0, centerTitle: false, - titleTextStyle: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + titleTextStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), ), cardTheme: CardThemeData( color: _darkSurface, @@ -220,6 +225,7 @@ class AppTheme { static ThemeData light({Color accent = defaultAccent}) { final accentPressed = _darken(accent, 0.16); + final tintedHeader = tintedSurface(_lightSurface, accent, 0.16); final scheme = ColorScheme( brightness: Brightness.light, @@ -245,13 +251,13 @@ class AppTheme { bodyColor: _lightTextMed, displayColor: _lightTextMed, ), - appBarTheme: const AppBarTheme( - backgroundColor: Colors.transparent, + appBarTheme: AppBarTheme( + backgroundColor: tintedHeader, surfaceTintColor: Colors.transparent, foregroundColor: _lightTextMed, elevation: 0, centerTitle: false, - titleTextStyle: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + titleTextStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), ), cardTheme: CardThemeData( color: _lightSurface, diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart index 00139e6..74acae5 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:du/core/theme/app_theme.dart'; import 'package:du/features/home/controller/home_controller.dart'; import 'package:du/features/home/model/home_state.dart'; import 'package:du/features/home/view/stat_box.dart'; @@ -156,8 +157,10 @@ class _HomePageState extends State { ); } + final theme = Theme.of(context); + final scheme = theme.colorScheme; final items = _itemsForFilter(st); - final accent = Theme.of(context).colorScheme.primary; + final accent = scheme.primary; return Scaffold( appBar: AppBar( @@ -187,14 +190,14 @@ class _HomePageState extends State { StatBox( label: 'Succeeded', count: st.succeededCount, - color: accent.withValues(alpha: 0.85), + color: accent, selected: _filter == ContractFilter.succeeded, onTap: () => setState(() => _filter = ContractFilter.succeeded), ), StatBox( label: 'Failed', count: st.failedCount, - color: Colors.redAccent, + color: scheme.onSurfaceVariant, selected: _filter == ContractFilter.failed, onTap: () => setState(() => _filter = ContractFilter.failed), ), @@ -220,10 +223,18 @@ class _HomePageState extends State { itemBuilder: (_, i) { final item = items[i]; - final tileColor = item.will - ? Colors.green.withValues(alpha: 0.08) - : Colors.red.withValues(alpha: 0.08); - final accentColor = item.will ? Colors.green : Colors.red; + final willColor = AppTheme.tintedSurface( + scheme.surface, + accent, + theme.brightness == Brightness.dark ? 0.28 : 0.16, + ); + final wontColor = AppTheme.tintedSurface( + scheme.surface, + scheme.onSurfaceVariant, + theme.brightness == Brightness.dark ? 0.24 : 0.12, + ); + final tileColor = item.will ? willColor : wontColor; + final accentColor = item.will ? accent : scheme.onSurfaceVariant; String? subtitleText = null; switch (_filter) { @@ -248,7 +259,7 @@ class _HomePageState extends State { IconButton( tooltip: 'Succeed', icon: const Icon(Icons.check_circle), - color: Colors.green, + color: accent, onPressed: () async { final ok = await _confirm( context, @@ -261,7 +272,7 @@ class _HomePageState extends State { IconButton( tooltip: 'Fail', icon: const Icon(Icons.not_interested), - color: Colors.red, + color: scheme.onSurfaceVariant, onPressed: () async { final ok = await _confirm( context, @@ -277,7 +288,7 @@ class _HomePageState extends State { IconButton( tooltip: 'Delete', icon: const Icon(Icons.delete), - color: Colors.grey, + color: scheme.onSurfaceVariant, onPressed: () async { final ok = await _confirm( context, @@ -298,7 +309,7 @@ class _HomePageState extends State { ? 'Forgive available in ${_fmt(left)}' : 'Forgive (remove)', icon: const Icon(Icons.volunteer_activism), - color: locked ? Colors.grey : Colors.redAccent, + color: locked ? scheme.onSurfaceVariant : accent, onPressed: locked ? null : () async { diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index 4a4bcf0..b989776 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -1,4 +1,5 @@ import 'package:du/core/app_profile/app_profile_controller.dart'; +import 'package:du/core/theme/app_theme.dart'; import 'package:flutter/material.dart'; class SettingsPage extends StatelessWidget { @@ -32,7 +33,7 @@ class SettingsPage extends StatelessWidget { body: ListView( padding: const EdgeInsets.all(24), children: [ - _AccentHero( + _MomentumCard( accent: accent, successes: successes, failures: failures, @@ -50,14 +51,13 @@ class SettingsPage extends StatelessWidget { const SizedBox(height: 28), const _SectionLabel('Accent color'), const SizedBox(height: 12), - _AccentSelector( - selected: accent, - options: controller.accentOptions, - onSelect: controller.setAccentColor, + _AccentPickerTile( + color: accent, + onChanged: controller.setAccentColor, ), const SizedBox(height: 8), Text( - 'This shade paints buttons, chips, and highlights everywhere. Tap to try a new mood.', + 'Pick a tone and we will carry it through the interface.', style: theme.textTheme.bodySmall, ), ], @@ -132,8 +132,8 @@ class _ThemeToggle extends StatelessWidget { } } -class _AccentHero extends StatelessWidget { - const _AccentHero({ +class _MomentumCard extends StatelessWidget { + const _MomentumCard({ required this.accent, required this.successes, required this.failures, @@ -152,77 +152,84 @@ class _AccentHero extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final tint = _tint(accent, 0.18); + final scheme = theme.colorScheme; + final background = AppTheme.tintedSurface( + scheme.surface, + accent, + theme.brightness == Brightness.dark ? 0.32 : 0.18, + ); + final borderColor = scheme.outline.withOpacity(0.28); + final textColor = scheme.onSurface; final ratioText = total == 0 ? '0%' : '${(ratio * 100).round()}%'; return Container( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(20), decoration: BoxDecoration( - gradient: LinearGradient( - colors: [accent, tint], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(28), + color: background, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: borderColor), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Momentum', - style: theme.textTheme.labelLarge?.copyWith( - color: Colors.white.withOpacity(0.78), - letterSpacing: 0.4, - ), - ), - const SizedBox(height: 12), Row( - crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - ratioText, - style: theme.textTheme.displaySmall?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w700, - height: 0.9, + 'Momentum', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: textColor, ), ), - const SizedBox(width: 12), - Expanded( - child: Text( - total == 0 - ? 'Log a contract to start the line moving.' - : 'All-time completion rate', - style: theme.textTheme.titleMedium?.copyWith( - color: Colors.white.withOpacity(0.88), - ), + const Spacer(), + Text( + ratioText, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: textColor, ), ), ], ), - const SizedBox(height: 10), + const SizedBox(height: 8), Text( - '$successes wins • $failures resets • $total logged', + total == 0 + ? 'Log a contract to start your streak.' + : 'All-time completion rate', style: theme.textTheme.bodySmall?.copyWith( - color: Colors.white.withOpacity(0.9), + color: textColor.withOpacity(0.72), ), ), - const SizedBox(height: 20), - SizedBox( - height: 120, + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(12), child: DecoratedBox( decoration: BoxDecoration( - color: Colors.white.withOpacity(0.12), - borderRadius: BorderRadius.circular(16), + color: scheme.surface.withOpacity( + theme.brightness == Brightness.dark ? 0.4 : 0.8, + ), ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: _MomentumGraph( - points: history, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: SizedBox( + height: 48, + child: _MomentumGraph( + points: history, + lineColor: accent, + trackColor: scheme.onSurfaceVariant.withOpacity(0.22), + emptyColor: textColor.withOpacity(0.55), + ), ), ), ), ), + const SizedBox(height: 12), + Text( + '$successes successes • $failures resets • $total logged', + style: theme.textTheme.bodySmall?.copyWith( + color: textColor.withOpacity(0.72), + ), + ), ], ), ); @@ -232,18 +239,24 @@ class _AccentHero extends StatelessWidget { class _MomentumGraph extends StatelessWidget { const _MomentumGraph({ required this.points, + required this.lineColor, + required this.trackColor, + required this.emptyColor, }); final List points; + final Color lineColor; + final Color trackColor; + final Color emptyColor; @override Widget build(BuildContext context) { if (points.isEmpty) { return Center( child: Text( - 'Keep logging to build your line.', + 'No momentum yet', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.white.withOpacity(0.85), + color: emptyColor, fontWeight: FontWeight.w600, ), textAlign: TextAlign.center, @@ -254,28 +267,39 @@ class _MomentumGraph extends StatelessWidget { return CustomPaint( painter: _SparklinePainter( points: points, - lineColor: Colors.white, - fillColor: Colors.white, + lineColor: lineColor, + trackColor: trackColor, ), ); } } class _SparklinePainter extends CustomPainter { - _SparklinePainter({ + const _SparklinePainter({ required this.points, required this.lineColor, - required this.fillColor, + required this.trackColor, }); final List points; final Color lineColor; - final Color fillColor; + final Color trackColor; @override void paint(Canvas canvas, Size size) { if (points.isEmpty) return; + final baseline = Paint() + ..color = trackColor + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke + ..isAntiAlias = true; + canvas.drawLine( + Offset(0, size.height - 1), + Offset(size.width, size.height - 1), + baseline, + ); + if (points.length == 1) { final value = points.first.clamp(0.0, 1.0); final y = size.height - (value * size.height); @@ -300,28 +324,9 @@ class _SparklinePainter extends CustomPainter { } } - final fillPath = Path.from(path) - ..lineTo(size.width, size.height) - ..lineTo(0, size.height) - ..close(); - - final fillPaint = Paint() - ..shader = LinearGradient( - colors: [ - fillColor.withOpacity(0.45), - fillColor.withOpacity(0.0), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) - ..style = PaintingStyle.fill - ..isAntiAlias = true; - - canvas.drawPath(fillPath, fillPaint); - final strokePaint = Paint() ..color = lineColor - ..strokeWidth = 3 + ..strokeWidth = 2.5 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round @@ -332,7 +337,7 @@ class _SparklinePainter extends CustomPainter { @override bool shouldRepaint(covariant _SparklinePainter oldDelegate) { - if (oldDelegate.lineColor != lineColor || oldDelegate.fillColor != fillColor) { + if (oldDelegate.lineColor != lineColor || oldDelegate.trackColor != trackColor) { return true; } if (oldDelegate.points.length != points.length) return true; @@ -343,109 +348,85 @@ class _SparklinePainter extends CustomPainter { } } -class _AccentSelector extends StatelessWidget { - const _AccentSelector({ - required this.selected, - required this.options, - required this.onSelect, - }); - - final Color selected; - final List options; - final ValueChanged onSelect; - - static const Map _labels = { - 0xFF3A7AFE: 'Sky', - 0xFF7C5CFF: 'Iris', - 0xFF4ED1A1: 'Mint', - 0xFFFF7A7A: 'Rose', - 0xFFFFB347: 'Amber', - }; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 94, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: options.length, - separatorBuilder: (_, __) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final color = options[index]; - final label = _labels[color.value] ?? '#${color.value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}'; - final selectedNow = color.value == selected.value; - return _AccentTile( - color: color, - label: label, - selected: selectedNow, - onTap: () => onSelect(color), - ); - }, - ), - ); - } -} - -class _AccentTile extends StatelessWidget { - const _AccentTile({ +class _AccentPickerTile extends StatelessWidget { + const _AccentPickerTile({ required this.color, - required this.label, - required this.selected, - required this.onTap, + required this.onChanged, }); final Color color; - final String label; - final bool selected; - final VoidCallback onTap; + final ValueChanged onChanged; @override Widget build(BuildContext context) { - final brightness = ThemeData.estimateBrightnessForColor(color); - final textColor = brightness == Brightness.dark ? Colors.white : Colors.black87; - final borderColor = brightness == Brightness.dark - ? Colors.white.withOpacity(selected ? 0.9 : 0.4) - : Colors.black.withOpacity(selected ? 0.3 : 0.18); + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final tinted = AppTheme.tintedSurface( + scheme.surface, + color, + theme.brightness == Brightness.dark ? 0.28 : 0.16, + ); return Material( color: Colors.transparent, child: InkWell( - onTap: onTap, borderRadius: BorderRadius.circular(18), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - width: 112, - padding: const EdgeInsets.all(16), + onTap: () async { + final picked = await showDialog( + context: context, + builder: (_) => _AccentPickerDialog(initial: color), + ); + if (picked != null) { + onChanged(picked); + } + }, + child: Container( + padding: const EdgeInsets.all(20), decoration: BoxDecoration( - color: selected ? color : color.withOpacity(0.85), borderRadius: BorderRadius.circular(18), - border: Border.all(color: borderColor, width: selected ? 2 : 1), - boxShadow: selected - ? [ - BoxShadow( - color: color.withOpacity(0.35), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ] - : null, + color: tinted, + border: Border.all(color: scheme.outline.withOpacity(0.2)), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Icon( - selected ? Icons.check_rounded : Icons.circle_outlined, - size: 18, - color: textColor.withOpacity(selected ? 1 : 0.7), + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: scheme.surface, + width: 2, + ), + ), ), - const Spacer(), - Text( - label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: textColor, - fontWeight: FontWeight.w600, + const SizedBox(width: 18), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Accent', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + _hexFromColor(color), + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + letterSpacing: 0.8, + ), ), + ], + ), + ), + Icon( + Icons.palette_outlined, + color: scheme.onSurfaceVariant, ), ], ), @@ -455,7 +436,165 @@ class _AccentTile extends StatelessWidget { } } -Color _tint(Color color, double amount) { - final hsl = HSLColor.fromColor(color); - return hsl.withLightness((hsl.lightness + amount).clamp(0.0, 1.0)).toColor(); +class _AccentPickerDialog extends StatefulWidget { + const _AccentPickerDialog({ + required this.initial, + }); + + final Color initial; + + @override + State<_AccentPickerDialog> createState() => _AccentPickerDialogState(); +} + +class _AccentPickerDialogState extends State<_AccentPickerDialog> { + late HSLColor _hsl; + + Color get _color => _hsl.toColor(); + + @override + void initState() { + super.initState(); + _hsl = HSLColor.fromColor(widget.initial); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final previewTextColor = _foregroundForColor(_color); + + return AlertDialog( + title: const Text('Choose accent'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 18), + decoration: BoxDecoration( + color: _color, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.outline.withOpacity(0.25)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _hexFromColor(_color), + style: theme.textTheme.titleMedium?.copyWith( + color: previewTextColor, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + 'Tap and drag the sliders to set an exact tone.', + style: theme.textTheme.bodySmall?.copyWith( + color: previewTextColor.withOpacity(0.85), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + _SliderRow( + label: 'Hue', + value: _hsl.hue, + max: 360, + onChanged: (value) => setState(() => _hsl = _hsl.withHue(value)), + accent: _color, + ), + const SizedBox(height: 12), + _SliderRow( + label: 'Saturation', + value: _hsl.saturation, + max: 1, + onChanged: (value) => setState(() => _hsl = _hsl.withSaturation(value)), + accent: _color, + ), + const SizedBox(height: 12), + _SliderRow( + label: 'Lightness', + value: _hsl.lightness, + max: 1, + onChanged: (value) => setState(() => _hsl = _hsl.withLightness(value)), + accent: _color, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, _color), + child: const Text('Use color'), + ), + ], + ); + } +} + +class _SliderRow extends StatelessWidget { + const _SliderRow({ + required this.label, + required this.value, + required this.max, + required this.onChanged, + required this.accent, + }); + + final String label; + final double value; + final double max; + final ValueChanged onChanged; + final Color accent; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), + ), + Text( + max == 360 ? value.toStringAsFixed(0) : value.toStringAsFixed(2), + style: theme.textTheme.bodySmall, + ), + ], + ), + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: accent, + thumbColor: accent, + ), + child: Slider( + value: value, + min: 0, + max: max, + onChanged: onChanged, + ), + ), + ], + ); + } +} + +String _hexFromColor(Color color) { + return '#${color.value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}'; +} + +Color _foregroundForColor(Color color) { + final brightness = ThemeData.estimateBrightnessForColor(color); + return brightness == Brightness.dark ? Colors.white : Colors.black87; } From 21540b4f89bcc794eb61eac0df0365ea6b921282 Mon Sep 17 00:00:00 2001 From: dylan-kramer Date: Fri, 14 Nov 2025 09:16:00 -0600 Subject: [PATCH 4/4] Refine settings visuals and tracking --- .../app_profile/app_profile_controller.dart | 39 ++- lib/core/data/app_profile_repository.dart | 19 +- lib/core/db/app_profile_record.dart | 8 +- lib/core/theme/app_theme.dart | 18 +- lib/features/home/view/home_page.dart | 92 +++++-- lib/features/settings/view/settings_page.dart | 238 ++++++++++++++---- 6 files changed, 331 insertions(+), 83 deletions(-) diff --git a/lib/core/app_profile/app_profile_controller.dart b/lib/core/app_profile/app_profile_controller.dart index 2ab1656..fa47ba2 100644 --- a/lib/core/app_profile/app_profile_controller.dart +++ b/lib/core/app_profile/app_profile_controller.dart @@ -23,8 +23,17 @@ class AppProfileController extends ChangeNotifier { int get successCount => _profile?.successCount ?? 0; int get failCount => _profile?.failCount ?? 0; int get totalResolved => successCount + failCount; - List get commitmentTrend => - List.unmodifiable(_profile?.commitmentTrend ?? const []); + List get commitmentTrend => + List.unmodifiable( + (_profile?.commitmentTrend ?? const []) + .map( + (entry) => CommitmentPoint( + ratio: entry.ratio, + recordedAt: entry.recordedAt, + ), + ) + .toList(), + ); double get commitmentRatio { final total = totalResolved; @@ -38,8 +47,14 @@ class AppProfileController extends ChangeNotifier { if (trackMomentum) { final total = totalResolved; if (total > 0) { - final updated = List.from(_profile!.commitmentTrend) - ..add(successCount / total); + final updated = List.from( + _profile!.commitmentTrend, + ) + ..add( + CommitmentTrendEntry() + ..ratio = successCount / total + ..recordedAt = DateTime.now(), + ); if (updated.length > _maxTrendPoints) { updated.removeRange(0, updated.length - _maxTrendPoints); } @@ -55,7 +70,11 @@ class AppProfileController extends ChangeNotifier { if (!_loading) return; _profile = await _repository.ensure(); if (_profile != null && _profile!.commitmentTrend.isEmpty && totalResolved > 0) { - _profile!.commitmentTrend = [commitmentRatio]; + _profile!.commitmentTrend = [ + CommitmentTrendEntry() + ..ratio = commitmentRatio + ..recordedAt = DateTime.now(), + ]; await _repository.save(_profile!); } _loading = false; @@ -90,3 +109,13 @@ class AppProfileController extends ChangeNotifier { List get accentOptions => List.unmodifiable(AppTheme.accentOptions); } + +class CommitmentPoint { + const CommitmentPoint({ + required this.ratio, + required this.recordedAt, + }); + + final double ratio; + final DateTime recordedAt; +} diff --git a/lib/core/data/app_profile_repository.dart b/lib/core/data/app_profile_repository.dart index 130896b..258b43e 100644 --- a/lib/core/data/app_profile_repository.dart +++ b/lib/core/data/app_profile_repository.dart @@ -26,24 +26,33 @@ class AppProfileRepository { .sortByCreatedAt() .findAll(); - final trend = []; + final trend = []; var wins = 0; var total = 0; for (final record in resolved) { total += 1; if (record.succeeded == true) wins += 1; - trend.add(wins / total); + trend.add( + CommitmentTrendEntry() + ..ratio = wins / total + ..recordedAt = record.createdAt ?? DateTime.now(), + ); } final totalOutcomes = successes + failures; if (trend.isEmpty && totalOutcomes > 0) { - trend.add(successes / totalOutcomes); + trend.add( + CommitmentTrendEntry() + ..ratio = successes / totalOutcomes + ..recordedAt = DateTime.now(), + ); } // keep the seeded history intentionally short to avoid bloating the profile record const seedLimit = 30; - final clippedTrend = - trend.length > seedLimit ? trend.sublist(trend.length - seedLimit) : trend; + final clippedTrend = trend.length > seedLimit + ? trend.sublist(trend.length - seedLimit) + : trend; final record = AppProfileRecord() ..successCount = successes diff --git a/lib/core/db/app_profile_record.dart b/lib/core/db/app_profile_record.dart index b2387e8..039b2a3 100644 --- a/lib/core/db/app_profile_record.dart +++ b/lib/core/db/app_profile_record.dart @@ -9,5 +9,11 @@ class AppProfileRecord { int accentColor = 0xFF3A7AFE; int successCount = 0; int failCount = 0; - List commitmentTrend = []; + List commitmentTrend = []; +} + +@embedded +class CommitmentTrendEntry { + double ratio = 0; + DateTime recordedAt = DateTime.now(); } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index d294eec..54bdc2c 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -34,15 +34,28 @@ class AppTheme { return Color.alphaBlend(accent.withOpacity(amount), surface); } + static Color _secondaryAccent(Color accent, {required bool darkMode}) { + final hsl = HSLColor.fromColor(accent); + final adjustedSaturation = (hsl.saturation * 0.55).clamp(0.0, 1.0); + final adjustedLightness = darkMode + ? (hsl.lightness + (1 - hsl.lightness) * 0.35) + : (hsl.lightness * 0.65); + return hsl + .withSaturation(adjustedSaturation) + .withLightness(adjustedLightness.clamp(0.0, 1.0)) + .toColor(); + } + static ThemeData dark({Color accent = defaultAccent}) { final accentPressed = _darken(accent, 0.18); final tintedHeader = tintedSurface(_darkSurface, accent, 0.18); + final secondary = _secondaryAccent(accent, darkMode: true); final scheme = ColorScheme( brightness: Brightness.dark, primary: accent, onPrimary: Colors.white, - secondary: accent, + secondary: secondary, onSecondary: Colors.white, surface: _darkSurface, onSurface: _darkTextMed, @@ -226,12 +239,13 @@ class AppTheme { static ThemeData light({Color accent = defaultAccent}) { final accentPressed = _darken(accent, 0.16); final tintedHeader = tintedSurface(_lightSurface, accent, 0.16); + final secondary = _secondaryAccent(accent, darkMode: false); final scheme = ColorScheme( brightness: Brightness.light, primary: accent, onPrimary: Colors.white, - secondary: accent, + secondary: secondary, onSecondary: Colors.white, surface: _lightSurface, onSurface: _lightTextMed, diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart index 74acae5..12c2dec 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -161,6 +161,7 @@ class _HomePageState extends State { final scheme = theme.colorScheme; final items = _itemsForFilter(st); final accent = scheme.primary; + final secondaryAccent = scheme.secondary; return Scaffold( appBar: AppBar( @@ -230,11 +231,15 @@ class _HomePageState extends State { ); final wontColor = AppTheme.tintedSurface( scheme.surface, - scheme.onSurfaceVariant, + secondaryAccent, theme.brightness == Brightness.dark ? 0.24 : 0.12, ); final tileColor = item.will ? willColor : wontColor; - final accentColor = item.will ? accent : scheme.onSurfaceVariant; + final accentColor = item.will ? accent : secondaryAccent; + final badgeLabel = item.will ? 'I will' : "I won't"; + final badgeColor = accentColor.withValues( + alpha: theme.brightness == Brightness.dark ? 0.28 : 0.16, + ); String? subtitleText = null; switch (_filter) { @@ -342,39 +347,72 @@ class _HomePageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: Text( - item.title, - style: TextStyle( - fontWeight: FontWeight.w600, - color: accentColor, - fontSize: 16, - ), - overflow: TextOverflow.ellipsis, - maxLines: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + item.title, + style: TextStyle( + fontWeight: FontWeight.w600, + color: accentColor, + fontSize: 16, + ), + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: badgeColor, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + badgeLabel, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: accentColor, + ), + ), + ), + ], + ), + if (subtitleText != null) ...[ + const SizedBox(height: 6), + Text( + subtitleText, + style: TextStyle( + color: accentColor.withValues(alpha: 0.8), + ), + ), + ], + ], ), ), ...actions, ], ), - if (subtitleText != null) ...[ - const SizedBox(height: 4), - Text( - subtitleText, - style: TextStyle( - color: accentColor.withValues(alpha: 0.8), - ), + if (_filter == ContractFilter.active) ...[ + const SizedBox(height: 6), + _reminderChipsRow( + contractId: item.id, + contractTitle: item.title, + will: item.will, + accentColor: accentColor, ), - if (_filter == ContractFilter.active) - _reminderChipsRow( - contractId: item.id, - contractTitle: item.title, - will: item.will, - accentColor: accentColor, - ), - ] + ], ], ), ), diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index b989776..c7f5f65 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:du/core/app_profile/app_profile_controller.dart'; import 'package:du/core/theme/app_theme.dart'; import 'package:flutter/material.dart'; @@ -147,7 +149,7 @@ class _MomentumCard extends StatelessWidget { final int failures; final double ratio; final int total; - final List history; + final List history; @override Widget build(BuildContext context) { @@ -161,6 +163,8 @@ class _MomentumCard extends StatelessWidget { final borderColor = scheme.outline.withOpacity(0.28); final textColor = scheme.onSurface; final ratioText = total == 0 ? '0%' : '${(ratio * 100).round()}%'; + final latestDate = history.isNotEmpty ? history.last.recordedAt : null; + final firstDate = history.isNotEmpty ? history.first.recordedAt : null; return Container( padding: const EdgeInsets.all(20), @@ -182,12 +186,24 @@ class _MomentumCard extends StatelessWidget { ), ), const Spacer(), - Text( - ratioText, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - color: textColor, - ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + ratioText, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: textColor, + ), + ), + if (latestDate != null) + Text( + 'Updated ${_formatShortDate(latestDate)}', + style: theme.textTheme.bodySmall?.copyWith( + color: textColor.withOpacity(0.65), + ), + ), + ], ), ], ), @@ -223,6 +239,28 @@ class _MomentumCard extends StatelessWidget { ), ), ), + if (history.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatShortDate(firstDate!), + style: theme.textTheme.bodySmall?.copyWith( + color: textColor.withOpacity(0.65), + ), + ), + Text( + _formatShortDate( + history.length == 1 ? firstDate! : history.last.recordedAt, + ), + style: theme.textTheme.bodySmall?.copyWith( + color: textColor.withOpacity(0.65), + ), + ), + ], + ), + ], const SizedBox(height: 12), Text( '$successes successes • $failures resets • $total logged', @@ -244,7 +282,7 @@ class _MomentumGraph extends StatelessWidget { required this.emptyColor, }); - final List points; + final List points; final Color lineColor; final Color trackColor; final Color emptyColor; @@ -264,9 +302,11 @@ class _MomentumGraph extends StatelessWidget { ); } + final ratios = points.map((p) => p.ratio).toList(); + return CustomPaint( painter: _SparklinePainter( - points: points, + points: ratios, lineColor: lineColor, trackColor: trackColor, ), @@ -348,6 +388,26 @@ class _SparklinePainter extends CustomPainter { } } +String _formatShortDate(DateTime date) { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + final month = months[date.month - 1]; + final includeYear = date.year != DateTime.now().year; + return includeYear ? '$month ${date.day}, ${date.year}' : '$month ${date.day}'; +} + class _AccentPickerTile extends StatelessWidget { const _AccentPickerTile({ required this.color, @@ -448,14 +508,14 @@ class _AccentPickerDialog extends StatefulWidget { } class _AccentPickerDialogState extends State<_AccentPickerDialog> { - late HSLColor _hsl; + late HSVColor _hsv; - Color get _color => _hsl.toColor(); + Color get _color => _hsv.toColor(); @override void initState() { super.initState(); - _hsl = HSLColor.fromColor(widget.initial); + _hsv = HSVColor.fromColor(widget.initial); } @override @@ -490,7 +550,7 @@ class _AccentPickerDialogState extends State<_AccentPickerDialog> { ), const SizedBox(height: 4), Text( - 'Tap and drag the sliders to set an exact tone.', + 'Drag around the wheel to set your tone. Use the slider to fine-tune brightness.', style: theme.textTheme.bodySmall?.copyWith( color: previewTextColor.withOpacity(0.85), ), @@ -499,27 +559,14 @@ class _AccentPickerDialogState extends State<_AccentPickerDialog> { ), ), const SizedBox(height: 20), - _SliderRow( - label: 'Hue', - value: _hsl.hue, - max: 360, - onChanged: (value) => setState(() => _hsl = _hsl.withHue(value)), - accent: _color, + _ColorWheelPicker( + color: _hsv, + onChanged: (value) => setState(() => _hsv = value), ), - const SizedBox(height: 12), - _SliderRow( - label: 'Saturation', - value: _hsl.saturation, - max: 1, - onChanged: (value) => setState(() => _hsl = _hsl.withSaturation(value)), - accent: _color, - ), - const SizedBox(height: 12), - _SliderRow( - label: 'Lightness', - value: _hsl.lightness, - max: 1, - onChanged: (value) => setState(() => _hsl = _hsl.withLightness(value)), + const SizedBox(height: 16), + _ValueSlider( + value: _hsv.value, + onChanged: (value) => setState(() => _hsv = _hsv.withValue(value)), accent: _color, ), ], @@ -539,18 +586,14 @@ class _AccentPickerDialogState extends State<_AccentPickerDialog> { } } -class _SliderRow extends StatelessWidget { - const _SliderRow({ - required this.label, +class _ValueSlider extends StatelessWidget { + const _ValueSlider({ required this.value, - required this.max, required this.onChanged, required this.accent, }); - final String label; final double value; - final double max; final ValueChanged onChanged; final Color accent; @@ -564,11 +607,11 @@ class _SliderRow extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - label, + 'Brightness', style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), ), Text( - max == 360 ? value.toStringAsFixed(0) : value.toStringAsFixed(2), + '${(value * 100).round()}%', style: theme.textTheme.bodySmall, ), ], @@ -579,9 +622,9 @@ class _SliderRow extends StatelessWidget { thumbColor: accent, ), child: Slider( - value: value, + value: value.clamp(0.0, 1.0), min: 0, - max: max, + max: 1, onChanged: onChanged, ), ), @@ -590,6 +633,115 @@ class _SliderRow extends StatelessWidget { } } +class _ColorWheelPicker extends StatelessWidget { + const _ColorWheelPicker({ + required this.color, + required this.onChanged, + }); + + final HSVColor color; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final side = math.min(constraints.maxWidth, 240.0); + + void update(Offset position) { + final center = Offset(side / 2, side / 2); + final vector = position - center; + final radius = side / 2; + final distance = math.min(vector.distance, radius); + final saturation = (distance / radius).clamp(0.0, 1.0); + var angle = math.atan2(vector.dy, vector.dx); + angle = angle < 0 ? angle + 2 * math.pi : angle; + final hue = angle * 180 / math.pi; + onChanged(color.withHue(hue).withSaturation(saturation)); + } + + return Center( + child: GestureDetector( + onPanDown: (details) => update(details.localPosition), + onPanUpdate: (details) => update(details.localPosition), + child: CustomPaint( + size: Size.square(side), + painter: _ColorWheelPainter(color: color), + ), + ), + ); + }, + ); + } +} + +class _ColorWheelPainter extends CustomPainter { + _ColorWheelPainter({required this.color}); + + final HSVColor color; + + @override + void paint(Canvas canvas, Size size) { + final radius = size.shortestSide / 2; + final center = Offset(size.width / 2, size.height / 2); + + final huePaint = Paint() + ..shader = SweepGradient( + colors: const [ + Color(0xFFFF0000), + Color(0xFFFFFF00), + Color(0xFF00FF00), + Color(0xFF00FFFF), + Color(0xFF0000FF), + Color(0xFFFF00FF), + Color(0xFFFF0000), + ], + ).createShader(Rect.fromCircle(center: center, radius: radius)); + canvas.drawCircle(center, radius, huePaint); + + final saturationPaint = Paint() + ..shader = RadialGradient( + colors: const [Colors.white, Colors.transparent], + stops: const [0.0, 1.0], + ).createShader(Rect.fromCircle(center: center, radius: radius)); + canvas.drawCircle(center, radius, saturationPaint); + + final valuePaint = Paint() + ..color = Colors.black.withOpacity(1 - color.value) + ..style = PaintingStyle.fill; + canvas.drawCircle(center, radius, valuePaint); + + final outlinePaint = Paint() + ..color = Colors.white.withOpacity(0.65) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + canvas.drawCircle(center, radius, outlinePaint); + + final indicatorRadius = color.saturation * radius; + final indicatorAngle = color.hue * math.pi / 180; + final indicatorCenter = Offset( + center.dx + indicatorRadius * math.cos(indicatorAngle), + center.dy + indicatorRadius * math.sin(indicatorAngle), + ); + + final indicatorFill = Paint() + ..color = color.toColor() + ..style = PaintingStyle.fill; + canvas.drawCircle(indicatorCenter, 8, indicatorFill); + + final indicatorBorder = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawCircle(indicatorCenter, 10, indicatorBorder); + } + + @override + bool shouldRepaint(covariant _ColorWheelPainter oldDelegate) { + return oldDelegate.color != color; + } +} + String _hexFromColor(Color color) { return '#${color.value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}'; }