mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 22:13:53 +01:00
Both platforms gain a calendar tab/screen (green/yellow/red/not-logged day coloring, logging/adherence streaks, year-to-date tally), and tapping a day drills into that day's logged entries. This requires the daily budget to become a normal, freely-editable value synced between devices like the food log — replaces the previous chattr +i seal (deliberately, per user decision) with plain last-edit-wins sync via a new budget.json alongside food_log.json. The budget field defaults to 2200 kcal, starts read-only, and unlocks via an Edit/Save toggle on both the PC gate's History tab and the phone app's Calendar screen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016dHac1iYQKTMZEtL7xDJvA
122 lines
4.2 KiB
Dart
122 lines
4.2 KiB
Dart
/// User-adjustable app settings persisted locally as JSON.
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
/// Singleton storing lightweight settings such as the daily kcal goal.
|
|
///
|
|
/// The static [dailyKcalGoal] getter returns the default (2200) when the
|
|
/// singleton has not been initialised — safe to read in widget tests that
|
|
/// never call [init]. [dailyKcalGoalUpdatedAt] is null until the goal has
|
|
/// been explicitly set (by the user or a sync merge) at least once on this
|
|
/// device -- that null is what tells the sync layer this device has
|
|
/// nothing of its own to contribute yet, rather than silently syncing the
|
|
/// unset default.
|
|
class AppSettingsService {
|
|
AppSettingsService._(this._file);
|
|
|
|
static AppSettingsService? _instance;
|
|
|
|
/// Returns the initialized singleton; throws if [init] was not called.
|
|
static AppSettingsService get instance => _instance!;
|
|
|
|
final File _file;
|
|
int _dailyKcalGoal = 2200;
|
|
DateTime? _dailyKcalGoalUpdatedAt;
|
|
|
|
/// Returns the configured daily kcal goal, or 2200 when uninitialised.
|
|
static int get dailyKcalGoal => _instance?._dailyKcalGoal ?? 2200;
|
|
|
|
/// Returns when the goal was last set on this device, or null if never.
|
|
static DateTime? get dailyKcalGoalUpdatedAt =>
|
|
_instance?._dailyKcalGoalUpdatedAt;
|
|
|
|
/// Initialises the singleton, pointing at the app's documents directory.
|
|
static Future<AppSettingsService> init() async {
|
|
if (_instance != null) return _instance!;
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
final svc = AppSettingsService._(
|
|
File(p.join(dir.path, 'app_settings.json')),
|
|
);
|
|
await svc._load();
|
|
_instance = svc;
|
|
return svc;
|
|
}
|
|
|
|
/// Resets the singleton so [init] can be called again in tests.
|
|
///
|
|
/// When [testDir] is given, reads/writes go there instead of the real
|
|
/// documents directory.
|
|
@visibleForTesting
|
|
static void resetForTesting({Directory? testDir}) {
|
|
_instance = testDir == null
|
|
? null
|
|
: AppSettingsService._(
|
|
File(p.join(testDir.path, 'app_settings.json')),
|
|
);
|
|
}
|
|
|
|
/// Initialises from [testDir], calling [_load], for use in unit tests.
|
|
///
|
|
/// Bypasses [getApplicationDocumentsDirectory] so tests don't need platform
|
|
/// channels.
|
|
@visibleForTesting
|
|
static Future<AppSettingsService> initForTesting(Directory testDir) async {
|
|
final svc = AppSettingsService._(
|
|
File(p.join(testDir.path, 'app_settings.json')),
|
|
);
|
|
await svc._load();
|
|
_instance = svc;
|
|
return svc;
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
if (!_file.existsSync()) return;
|
|
try {
|
|
final data = jsonDecode(await _file.readAsString());
|
|
if (data is Map && data['daily_kcal_goal'] is int) {
|
|
_dailyKcalGoal = data['daily_kcal_goal'] as int;
|
|
}
|
|
if (data is Map && data['daily_kcal_goal_updated_at'] is String) {
|
|
_dailyKcalGoalUpdatedAt = DateTime.tryParse(
|
|
data['daily_kcal_goal_updated_at'] as String,
|
|
);
|
|
}
|
|
} on Exception {
|
|
// Ignore parse errors and keep defaults.
|
|
}
|
|
}
|
|
|
|
/// Updates the in-memory value and persists [goal] to disk, stamping the
|
|
/// edit with the current time -- the timestamp a sync merge compares
|
|
/// against another device's edit to resolve last-writer-wins.
|
|
Future<void> saveDailyKcalGoal(int goal) => _persist(goal, DateTime.now());
|
|
|
|
/// Applies a synced budget value without stamping a fresh edit time.
|
|
///
|
|
/// Used only by the sync layer to write back a merge's winning value: it
|
|
/// persists [updatedAt] verbatim (the winning side's real edit time), not
|
|
/// "now", so re-syncing an unchanged value stays idempotent instead of
|
|
/// making this device's copy look newer than it actually is on every
|
|
/// tick.
|
|
Future<void> applySyncedBudget(int goal, {DateTime? updatedAt}) =>
|
|
_persist(goal, updatedAt);
|
|
|
|
Future<void> _persist(int goal, DateTime? updatedAt) async {
|
|
_dailyKcalGoal = goal;
|
|
_dailyKcalGoalUpdatedAt = updatedAt;
|
|
await _file.parent.create(recursive: true);
|
|
await _file.writeAsString(
|
|
jsonEncode({
|
|
'daily_kcal_goal': goal,
|
|
'daily_kcal_goal_updated_at': updatedAt?.toIso8601String(),
|
|
}),
|
|
);
|
|
}
|
|
}
|