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..fa47ba2 --- /dev/null +++ b/lib/core/app_profile/app_profile_controller.dart @@ -0,0 +1,121 @@ +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._(); + + static const int _maxTrendPoints = 30; + + 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; + List get commitmentTrend => + List.unmodifiable( + (_profile?.commitmentTrend ?? const []) + .map( + (entry) => CommitmentPoint( + ratio: entry.ratio, + recordedAt: entry.recordedAt, + ), + ) + .toList(), + ); + + double get commitmentRatio { + final total = totalResolved; + if (total == 0) return 0; + 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( + CommitmentTrendEntry() + ..ratio = successCount / total + ..recordedAt = DateTime.now(), + ); + 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 = [ + CommitmentTrendEntry() + ..ratio = commitmentRatio + ..recordedAt = DateTime.now(), + ]; + await _repository.save(_profile!); + } + _loading = false; + notifyListeners(); + } + + Future setDarkMode(bool value) async { + if (_profile == null) return; + if (_profile!.darkMode == value) return; + _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 _persist(); + } + + Future recordSuccess() async { + if (_profile == null) return; + _profile!.successCount += 1; + await _persist(trackMomentum: true); + } + + Future recordFailure() async { + if (_profile == null) return; + _profile!.failCount += 1; + await _persist(trackMomentum: true); + } + + 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 new file mode 100644 index 0000000..258b43e --- /dev/null +++ b/lib/core/data/app_profile_repository.dart @@ -0,0 +1,75 @@ +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 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( + CommitmentTrendEntry() + ..ratio = wins / total + ..recordedAt = record.createdAt ?? DateTime.now(), + ); + } + + final totalOutcomes = successes + failures; + if (trend.isEmpty && totalOutcomes > 0) { + 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 record = AppProfileRecord() + ..successCount = successes + ..failCount = failures + ..commitmentTrend = clippedTrend; + + 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..039b2a3 --- /dev/null +++ b/lib/core/db/app_profile_record.dart @@ -0,0 +1,19 @@ +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; + List commitmentTrend = []; +} + +@embedded +class CommitmentTrendEntry { + double ratio = 0; + DateTime recordedAt = DateTime.now(); +} 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..54bdc2c 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -1,169 +1,435 @@ 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 _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 Color tintedSurface(Color surface, Color accent, [double amount = 0.08]) { + return Color.alphaBlend(accent.withOpacity(amount), surface); + } - outlinedButtonTheme: OutlinedButtonThemeData( - style: ButtonStyle( - side: const WidgetStatePropertyAll(BorderSide(color: _outline)), - foregroundColor: const WidgetStatePropertyAll(_textMed), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ), - ), + 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(); + } - textButtonTheme: TextButtonThemeData( - style: ButtonStyle( - foregroundColor: const WidgetStatePropertyAll(_primary), - overlayColor: WidgetStatePropertyAll(_primary.withValues(alpha: 0.08)), - ), - ), + 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: secondary, + onSecondary: Colors.white, + surface: _darkSurface, + onSurface: _darkTextMed, + surfaceContainerHighest: _darkSurfaceV, + onSurfaceVariant: _darkTextLo, + error: _error, + onError: Colors.white, + outline: _darkOutline, + ); - // 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), - ), + return ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: _darkBg, + colorScheme: scheme, + textTheme: Typography.whiteMountainView.apply( + bodyColor: _darkTextMed, + displayColor: _darkTextMed, + ), + appBarTheme: AppBarTheme( + backgroundColor: tintedHeader, + foregroundColor: _darkTextMed, + elevation: 0, + centerTitle: false, + titleTextStyle: const 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.28), + 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: IconThemeData(color: accent.withValues(alpha: 0.8)), + 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, + ), + ), + 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), + ); + } - // 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), - ), - ), + 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); - dividerTheme: const DividerThemeData( - color: _outline, - space: 1, - thickness: 1, - ), + final scheme = 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, + ); - 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: scheme, + textTheme: Typography.blackMountainView.apply( + bodyColor: _lightTextMed, + displayColor: _lightTextMed, + ), + appBarTheme: AppBarTheme( + backgroundColor: tintedHeader, + surfaceTintColor: Colors.transparent, + foregroundColor: _lightTextMed, + elevation: 0, + centerTitle: false, + titleTextStyle: const 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.24), + 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: IconThemeData(color: accent.withValues(alpha: 0.8)), + 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, + ), + ), + 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), + ); + } +} - 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..12c2dec 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,10 +157,23 @@ class _HomePageState extends State { ); } + final theme = Theme.of(context); + final scheme = theme.colorScheme; final items = _itemsForFilter(st); + final accent = scheme.primary; + final secondaryAccent = scheme.secondary; 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( @@ -170,21 +184,21 @@ 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, 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), ), @@ -210,10 +224,22 @@ 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, + secondaryAccent, + theme.brightness == Brightness.dark ? 0.24 : 0.12, + ); + final tileColor = item.will ? willColor : wontColor; + 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) { @@ -238,7 +264,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, @@ -251,7 +277,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, @@ -267,7 +293,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, @@ -288,7 +314,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 { @@ -321,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 new file mode 100644 index 0000000..c7f5f65 --- /dev/null +++ b/lib/features/settings/view/settings_page.dart @@ -0,0 +1,752 @@ +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'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + @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 accent = controller.accentColor; + final successes = controller.successCount; + final failures = controller.failCount; + final total = controller.totalResolved; + final ratio = controller.commitmentRatio; + final history = controller.commitmentTrend; + + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + ), + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + _MomentumCard( + 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), + _AccentPickerTile( + color: accent, + onChanged: controller.setAccentColor, + ), + const SizedBox(height: 8), + Text( + 'Pick a tone and we will carry it through the interface.', + 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.start, + children: [ + Text( + 'Dark mode', + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + 'Match the interface to your focus window.', + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + Switch.adaptive( + value: darkMode, + onChanged: onChanged, + ), + ], + ), + ); + } +} + +class _MomentumCard extends StatelessWidget { + const _MomentumCard({ + required this.accent, + required this.successes, + required this.failures, + required this.ratio, + required this.total, + required this.history, + }); + + final Color accent; + final int successes; + final int failures; + final double ratio; + final int total; + final List history; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + 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()}%'; + final latestDate = history.isNotEmpty ? history.last.recordedAt : null; + final firstDate = history.isNotEmpty ? history.first.recordedAt : null; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Momentum', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + const Spacer(), + 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), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 8), + Text( + total == 0 + ? 'Log a contract to start your streak.' + : 'All-time completion rate', + style: theme.textTheme.bodySmall?.copyWith( + color: textColor.withOpacity(0.72), + ), + ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: DecoratedBox( + decoration: BoxDecoration( + color: scheme.surface.withOpacity( + theme.brightness == Brightness.dark ? 0.4 : 0.8, + ), + ), + child: Padding( + 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), + ), + ), + ), + ), + ), + 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', + style: theme.textTheme.bodySmall?.copyWith( + color: textColor.withOpacity(0.72), + ), + ), + ], + ), + ); + } +} + +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( + 'No momentum yet', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: emptyColor, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + ); + } + + final ratios = points.map((p) => p.ratio).toList(); + + return CustomPaint( + painter: _SparklinePainter( + points: ratios, + lineColor: lineColor, + trackColor: trackColor, + ), + ); + } +} + +class _SparklinePainter extends CustomPainter { + const _SparklinePainter({ + required this.points, + required this.lineColor, + required this.trackColor, + }); + + final List points; + final Color lineColor; + 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); + 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 strokePaint = Paint() + ..color = lineColor + ..strokeWidth = 2.5 + ..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.trackColor != trackColor) { + 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; + } +} + +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, + required this.onChanged, + }); + + final Color color; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + 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( + borderRadius: BorderRadius.circular(18), + 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( + borderRadius: BorderRadius.circular(18), + color: tinted, + border: Border.all(color: scheme.outline.withOpacity(0.2)), + ), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: scheme.surface, + width: 2, + ), + ), + ), + 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, + ), + ], + ), + ), + ), + ); + } +} + +class _AccentPickerDialog extends StatefulWidget { + const _AccentPickerDialog({ + required this.initial, + }); + + final Color initial; + + @override + State<_AccentPickerDialog> createState() => _AccentPickerDialogState(); +} + +class _AccentPickerDialogState extends State<_AccentPickerDialog> { + late HSVColor _hsv; + + Color get _color => _hsv.toColor(); + + @override + void initState() { + super.initState(); + _hsv = HSVColor.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( + '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), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + _ColorWheelPicker( + color: _hsv, + onChanged: (value) => setState(() => _hsv = value), + ), + const SizedBox(height: 16), + _ValueSlider( + value: _hsv.value, + onChanged: (value) => setState(() => _hsv = _hsv.withValue(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 _ValueSlider extends StatelessWidget { + const _ValueSlider({ + required this.value, + required this.onChanged, + required this.accent, + }); + + final double value; + 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( + 'Brightness', + style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), + ), + Text( + '${(value * 100).round()}%', + style: theme.textTheme.bodySmall, + ), + ], + ), + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: accent, + thumbColor: accent, + ), + child: Slider( + value: value.clamp(0.0, 1.0), + min: 0, + max: 1, + onChanged: onChanged, + ), + ), + ], + ); + } +} + +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()}'; +} + +Color _foregroundForColor(Color color) { + final brightness = ThemeData.estimateBrightnessForColor(color); + return brightness == Brightness.dark ? Colors.white : Colors.black87; +} 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()); }