Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
34 changes: 29 additions & 5 deletions lib/app/app.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
);
},
);
}
}
7 changes: 6 additions & 1 deletion lib/app/router.dart
Original file line number Diff line number Diff line change
@@ -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: [
Expand All @@ -12,5 +13,9 @@ final GoRouter appRouter = GoRouter(
path: '/contract',
builder: (context, state) => const ContractPage(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
),
],
);
121 changes: 121 additions & 0 deletions lib/core/app_profile/app_profile_controller.dart
Original file line number Diff line number Diff line change
@@ -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<CommitmentPoint> get commitmentTrend =>
List<CommitmentPoint>.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<void> _persist({bool trackMomentum = false}) async {
if (_profile == null) return;

if (trackMomentum) {
final total = totalResolved;
if (total > 0) {
final updated = List<CommitmentTrendEntry>.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<void> 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<void> setDarkMode(bool value) async {
if (_profile == null) return;
if (_profile!.darkMode == value) return;
_profile!.darkMode = value;
await _persist();
}

Future<void> setAccentColor(Color color) async {
if (_profile == null) return;
if (_profile!.accentColor == color.value) return;
_profile!.accentColor = color.value;
await _persist();
}

Future<void> recordSuccess() async {
if (_profile == null) return;
_profile!.successCount += 1;
await _persist(trackMomentum: true);
}

Future<void> recordFailure() async {
if (_profile == null) return;
_profile!.failCount += 1;
await _persist(trackMomentum: true);
}

List<Color> get accentOptions => List<Color>.unmodifiable(AppTheme.accentOptions);
}

class CommitmentPoint {
const CommitmentPoint({
required this.ratio,
required this.recordedAt,
});

final double ratio;
final DateTime recordedAt;
}
75 changes: 75 additions & 0 deletions lib/core/data/app_profile_repository.dart
Original file line number Diff line number Diff line change
@@ -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<Isar> get _db => AppDb.instance();

Future<AppProfileRecord> 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 = <CommitmentTrendEntry>[];
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<AppProfileRecord> save(AppProfileRecord record) async {
final isar = await _db;
await isar.writeTxn(() async {
await isar.appProfileRecords.put(record);
});
return record;
}
}
19 changes: 19 additions & 0 deletions lib/core/db/app_profile_record.dart
Original file line number Diff line number Diff line change
@@ -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<CommitmentTrendEntry> commitmentTrend = [];
}

@embedded
class CommitmentTrendEntry {
double ratio = 0;
DateTime recordedAt = DateTime.now();
}
4 changes: 3 additions & 1 deletion lib/core/db/isar_db.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -12,7 +13,8 @@ class AppDb {
_instance = await Isar.open(
[
ContractRecordSchema,
ReminderRecordSchema
ReminderRecordSchema,
AppProfileRecordSchema,
],
directory: dir.path,
inspector: !bool.fromEnvironment('dart.vm.product'),
Expand Down
Loading