Add Flutter half of cross-device sync (Milestone 3)

Ports github_client.dart and sync_settings.dart from ~/todo (PAT-paste
instead of OAuth device flow), and writes a new (non-CRDT) sync_merge.dart
and sync_service.dart matching diet_guard's Python _sync_merge.py/_sync.py
algorithm exactly. Adds a settings screen for the PAT plus manual "Sync
now", and wires lifecycle-triggered auto-sync (launch + resumed/paused)
into the main logging screen, silent on failure per plan decision 4.

Also adds Linux desktop platform scaffolding so this and future UI changes
can be visually verified without a connected phone.

Verified end-to-end against the real kuhyx/diet-guard-sync GitHub API on a
Linux desktop build: Test connection and Sync now both round-trip to GitHub
and surface real auth errors correctly via SnackBar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RH2BHCKbDTiYJUMG3rb9nq
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-22 22:42:27 +02:00
parent e5b80fd610
commit a82047502f
27 changed files with 2438 additions and 4 deletions

View File

@ -15,7 +15,7 @@ migration:
- platform: root
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
- platform: android
- platform: linux
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020

View File

@ -9,26 +9,36 @@ import 'package:diet_guard_app/models/nutrition.dart';
import 'package:diet_guard_app/models/slot.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/screens/meal_builder_screen.dart';
import 'package:diet_guard_app/screens/settings_screen.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/github_client.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/sync_service.dart';
import 'package:diet_guard_app/services/sync_settings.dart';
import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart';
import 'package:diet_guard_app/widgets/macro_input_row.dart';
import 'package:diet_guard_app/widgets/photo_attach_field.dart';
import 'package:diet_guard_app/widgets/slot_status_bar.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
/// Lets the user log one food item, with food-bank autocomplete and
/// today's slot status, or hop into [MealBuilderScreen] for a composite
/// multi-item meal.
class LogMealScreen extends StatefulWidget {
/// Creates a [LogMealScreen].
const LogMealScreen({super.key});
const LogMealScreen({super.key, this.httpClient});
/// Injectable HTTP client for auto-sync; tests pass a [MockClient].
/// Production leaves this null so [GitHubClient] builds a real one.
final http.Client? httpClient;
@override
State<LogMealScreen> createState() => _LogMealScreenState();
}
class _LogMealScreenState extends State<LogMealScreen> {
class _LogMealScreenState extends State<LogMealScreen>
with WidgetsBindingObserver {
final TextEditingController _descController = TextEditingController();
final MacroControllers _macros = MacroControllers();
List<FoodSuggestion> _suggestions = const [];
@ -37,9 +47,13 @@ class _LogMealScreenState extends State<LogMealScreen> {
String? _status;
String? _imagePath;
/// Single-flight guard so a launch sync and a lifecycle sync never overlap.
bool _autoSyncing = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_descController.addListener(_onDescChanged);
for (final controller in [
_macros.kcal,
@ -53,15 +67,69 @@ class _LogMealScreenState extends State<LogMealScreen> {
}
unawaited(_refreshSlots());
unawaited(_onDescChanged());
unawaited(_autoSync());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_descController.dispose();
_macros.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Pull on resume (catch up on what another device logged while this one
// was backgrounded) and push on pause (keep the remote near-current).
final isResumeOrPause =
state == AppLifecycleState.resumed || state == AppLifecycleState.paused;
if (isResumeOrPause) {
unawaited(_autoSync());
}
}
/// Best-effort background sync: silent, skips when unconfigured, and never
/// overlaps itself. Failures are swallowed -- the Settings screen's manual
/// "Sync now" is where errors get surfaced. The try wraps even loading
/// [SyncSettings] itself: under `flutter test`, the shared_preferences and
/// secure-storage platform channels are unmocked by default and throw
/// [MissingPluginException], which must degrade exactly like "offline"
/// rather than crash every screen that mounts this widget.
///
/// [_refreshSlots] only runs after an actual sync (not on the unconfigured
/// path, which every existing screen test takes): a fire-and-forget tail
/// await here can resolve after a *later* test's `tearDown` has already
/// reset [LogStorageService]'s singleton -- `mounted` alone doesn't bound
/// that, since widget disposal between tests isn't synchronized with a
/// still-pending Future from an earlier one.
Future<void> _autoSync() async {
if (_autoSyncing) return;
_autoSyncing = true;
try {
final settings = await SyncSettings.load();
if (!settings.isConfigured) return;
final client = GitHubClient(
owner: settings.owner,
repo: settings.repo,
token: settings.token,
httpClient: widget.httpClient,
);
try {
await runSync(client);
} finally {
client.close();
}
if (!mounted) return;
await _refreshSlots();
} on Exception {
// Best-effort: ignore (offline, transient GitHub errors, unmocked
// platform channels under test, etc.).
} finally {
_autoSyncing = false;
}
}
Future<void> _refreshSlots() async {
final logged = await LogStorageService.instance.loggedSlotsToday();
if (!mounted) return;
@ -150,6 +218,16 @@ class _LogMealScreenState extends State<LogMealScreen> {
);
}
void _onOpenSettings() {
unawaited(
Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => SettingsScreen(httpClient: widget.httpClient),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -161,6 +239,11 @@ class _LogMealScreenState extends State<LogMealScreen> {
tooltip: 'History',
onPressed: _onOpenHistory,
),
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Sync settings',
onPressed: _onOpenSettings,
),
],
),
body: SingleChildScrollView(

View File

@ -0,0 +1,180 @@
/// GitHub sync configuration: paste a PAT, test the connection, and trigger
/// a manual sync. Auto-sync (app launch + lifecycle pause/resume) lives in
/// [LogMealScreen] and is silent on failure -- this screen is where errors
/// get surfaced, via [SnackBar].
library;
import 'dart:async';
import 'package:diet_guard_app/screens/log_meal_screen.dart';
import 'package:diet_guard_app/services/github_client.dart';
import 'package:diet_guard_app/services/sync_service.dart';
import 'package:diet_guard_app/services/sync_settings.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
/// Screen for configuring and triggering cross-device sync.
class SettingsScreen extends StatefulWidget {
/// Creates a [SettingsScreen].
const SettingsScreen({super.key, this.httpClient});
/// Injectable HTTP client; tests pass a [MockClient].
final http.Client? httpClient;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final _ownerController = TextEditingController();
final _repoController = TextEditingController();
final _tokenController = TextEditingController();
bool _loading = true;
bool _busy = false;
@override
void initState() {
super.initState();
unawaited(_load());
}
/// Loads saved settings, defaulting to blank fields if loading itself
/// fails (e.g. no secret service available yet) -- the screen must still
/// render, not spin forever, so the user can fill them in from scratch.
Future<void> _load() async {
SyncSettings settings;
try {
settings = await SyncSettings.load();
} on Exception {
settings = const SyncSettings(owner: '', repo: '', token: '');
}
if (!mounted) return;
_ownerController.text = settings.owner;
_repoController.text = settings.repo;
_tokenController.text = settings.token;
setState(() => _loading = false);
}
@override
void dispose() {
_ownerController.dispose();
_repoController.dispose();
_tokenController.dispose();
super.dispose();
}
SyncSettings _currentSettings() => SyncSettings(
owner: _ownerController.text.trim(),
repo: _repoController.text.trim(),
token: _tokenController.text.trim(),
);
void _showMessage(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
Future<void> _save() async {
setState(() => _busy = true);
await _currentSettings().save();
if (!mounted) return;
setState(() => _busy = false);
_showMessage('Saved.');
}
Future<void> _testConnection() async {
setState(() => _busy = true);
final settings = _currentSettings();
final client = GitHubClient(
owner: settings.owner,
repo: settings.repo,
token: settings.token,
httpClient: widget.httpClient,
);
try {
final ok = await client.canAccessRepo();
_showMessage(ok ? 'Connection OK.' : 'Connection failed.');
} on Exception catch (e) {
_showMessage('Connection failed: $e');
} finally {
client.close();
if (mounted) setState(() => _busy = false);
}
}
Future<void> _syncNow() async {
setState(() => _busy = true);
final settings = _currentSettings();
await settings.save();
final client = GitHubClient(
owner: settings.owner,
repo: settings.repo,
token: settings.token,
httpClient: widget.httpClient,
);
try {
await runSync(client);
_showMessage('Synced.');
} on Exception catch (e) {
_showMessage('Sync failed: $e');
} finally {
client.close();
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(title: const Text('Sync settings')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _ownerController,
decoration: const InputDecoration(labelText: 'GitHub owner'),
),
const SizedBox(height: 8),
TextField(
controller: _repoController,
decoration: const InputDecoration(labelText: 'Repo'),
),
const SizedBox(height: 8),
TextField(
controller: _tokenController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Personal access token',
),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
children: [
ElevatedButton(
onPressed: _busy ? null : _save,
child: const Text('Save'),
),
OutlinedButton(
onPressed: _busy ? null : _testConnection,
child: const Text('Test connection'),
),
ElevatedButton(
onPressed: _busy ? null : _syncNow,
child: const Text('Sync now'),
),
],
),
],
),
),
);
}
}

View File

@ -0,0 +1,203 @@
/// Minimal GitHub Contents API client, ported verbatim from `~/todo`'s
/// `sync/github_client.dart` -- it is already generic over owner/repo/token
/// and has nothing todo-specific to strip.
library;
import 'dart:convert';
import 'package:http/http.dart' as http;
/// A file entry in a GitHub repository directory listing.
class GitHubFile {
/// Creates a [GitHubFile] from its name/path/sha.
const GitHubFile({
required this.name,
required this.path,
required this.sha,
});
/// The entry's bare file name (no path prefix).
final String name;
/// The entry's full repo-relative path.
final String path;
/// Git blob SHA; required to update or delete the file.
final String sha;
}
/// Raised when the GitHub API returns an unexpected status.
class GitHubApiException implements Exception {
/// Creates an exception for the given [statusCode]/[message].
GitHubApiException(this.statusCode, this.message);
/// The HTTP status code returned by the API.
final int statusCode;
/// A human-readable description of what failed.
final String message;
@override
String toString() => 'GitHubApiException($statusCode): $message';
}
/// Minimal GitHub REST client scoped to the Contents API.
///
/// This is the only component that holds the access token, mirroring the
/// "server holds credentials" pattern: the rest of the app deals in food
/// entries and merged logs, never in raw HTTP or secrets.
class GitHubClient {
/// Creates a client scoped to one repo, authenticated with [token].
GitHubClient({
required this.owner,
required this.repo,
required String token,
http.Client? httpClient,
this.branch = 'main',
}) // Dart forbids private named params, so this can't be an initializing
// formal; assign it explicitly.
// ignore: prefer_initializing_formals
: _token = token,
_http = httpClient ?? http.Client();
/// The repo owner/org (e.g. `"kuhyx"`).
final String owner;
/// The repo name (e.g. `"diet-guard-sync"`).
final String repo;
/// The branch to read/write against.
final String branch;
final String _token;
final http.Client _http;
static const _apiBase = 'https://api.github.com';
Map<String, String> get _headers => {
'Authorization': 'Bearer $_token',
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'diet-guard-app-sync',
};
Uri _contentsUri(String path) =>
Uri.parse('$_apiBase/repos/$owner/$repo/contents/$path');
/// Lists the files directly under [dirPath]. Returns an empty list if the
/// directory does not exist yet (e.g. before the first sync).
Future<List<GitHubFile>> listDirectory(String dirPath) async {
final res = await _http.get(
_contentsUri(dirPath).replace(queryParameters: {'ref': branch}),
headers: _headers,
);
if (res.statusCode == 404) return [];
_ensureOk(res, 'list $dirPath');
final decoded = jsonDecode(res.body);
if (decoded is! List) return [];
return decoded
.cast<Map<String, dynamic>>()
.where((e) => e['type'] == 'file')
.map(
(e) => GitHubFile(
name: e['name'] as String,
path: e['path'] as String,
sha: e['sha'] as String,
),
)
.toList();
}
/// Lists the names of every entry (file *or* directory) directly under
/// [dirPath]. Returns an empty list if the directory does not exist yet.
///
/// Unlike [listDirectory] (files-only, for a flat layout), this also
/// surfaces subdirectory names -- needed for diet_guard's per-device
/// `devices/<id>/food_log.json` layout, where each device id is itself a
/// directory one level above its file.
Future<List<String>> listEntryNames(String dirPath) async {
final res = await _http.get(
_contentsUri(dirPath).replace(queryParameters: {'ref': branch}),
headers: _headers,
);
if (res.statusCode == 404) return [];
_ensureOk(res, 'list $dirPath');
final decoded = jsonDecode(res.body);
if (decoded is! List) return [];
return decoded
.cast<Map<String, dynamic>>()
.map((e) => e['name'] as String)
.toList();
}
/// Fetches and UTF-8-decodes a file's contents. Returns null if absent.
Future<String?> getFileText(String path) async {
final res = await _http.get(
_contentsUri(path).replace(queryParameters: {'ref': branch}),
headers: _headers,
);
if (res.statusCode == 404) return null;
_ensureOk(res, 'get $path');
final json = jsonDecode(res.body) as Map<String, dynamic>;
// GitHub base64-encodes file content, wrapping lines at 60 chars.
final raw = (json['content'] as String).replaceAll('\n', '');
return utf8.decode(base64.decode(raw));
}
/// Creates or updates [path] with [text]. Pass the current [sha] when
/// updating an existing file; omit it to create a new one.
Future<void> putFileText(
String path,
String text, {
String? sha,
String? message,
}) async {
final body = <String, dynamic>{
'message': message ?? 'sync: update $path',
'content': base64.encode(utf8.encode(text)),
'branch': branch,
'sha': ?sha,
};
final res = await _http.put(
_contentsUri(path),
headers: _headers,
body: jsonEncode(body),
);
_ensureOk(res, 'put $path');
}
/// Deletes the file at [path] (requires its current [sha]).
Future<void> deleteFile(String path, String sha, {String? message}) async {
final res = await _http.delete(
_contentsUri(path),
headers: _headers,
body: jsonEncode({
'message': message ?? 'sync: delete $path',
'sha': sha,
'branch': branch,
}),
);
_ensureOk(res, 'delete $path');
}
/// Cheap auth/connectivity probe used by the settings "Test connection"
/// button: succeeds only if the token can read the repo.
Future<bool> canAccessRepo() async {
final res = await _http.get(
Uri.parse('$_apiBase/repos/$owner/$repo'),
headers: _headers,
);
return res.statusCode == 200;
}
/// Closes the underlying HTTP client.
void close() => _http.close();
void _ensureOk(http.Response res, String action) {
if (res.statusCode < 200 || res.statusCode >= 300) {
throw GitHubApiException(
res.statusCode,
'Failed to $action: ${res.body}',
);
}
}
}

View File

@ -0,0 +1,69 @@
/// Pure log-merge logic for diet_guard's cross-device sync.
///
/// No I/O here -- this module is unit-testable purely on in-memory [DayLog]
/// values. Mirrored test-for-test against the Python original
/// (`diet_guard/_sync_merge.py`), so the merge algorithm canonically agrees
/// on both sides of the sync.
library;
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
/// A dedup key: `('id', <uuid>)` for any entry with one, else
/// `('legacy', (time, desc))` for a pre-id entry written before this field
/// existed -- two devices that both already had that same legacy entry would
/// otherwise end up with two copies of it after a merge.
typedef _MergeKey = (String, Object);
_MergeKey _entryKey(FoodEntry entry) {
final id = entry.id;
if (id != null && id.isNotEmpty) return ('id', id);
return ('legacy', (entry.time, entry.desc));
}
/// Returns true if [candidate] should replace [existing] for one key.
///
/// A tombstone always wins over a non-tombstoned copy of the same entry --
/// deletion is sticky, so a stale pre-undo copy pulled from another device
/// can never resurrect something the user explicitly removed. Otherwise,
/// keep whichever copy was seen first: two copies of the same id are
/// expected to be identical in their macros/desc (the body is never mutated
/// after creation, only `deleted`/`hmac`), so which one survives does not
/// change the merged result's content.
bool _tombstoneWins(FoodEntry candidate, FoodEntry existing) =>
candidate.deleted && !existing.deleted;
/// Returns the union of [local] and [remote], tombstones winning by id.
///
/// Commutative and idempotent: `mergeLogs(a, b) == mergeLogs(b, a)` and
/// `mergeLogs(x, x) == x` (for an `x` with no duplicate keys), so pull-order
/// between devices never matters and a repeated sync tick is a no-op. Each
/// entry is re-bucketed under its own `time`'s date rather than the date key
/// it arrived under, so a merge can't silently leave an entry filed under
/// the wrong day.
DayLog mergeLogs(DayLog local, DayLog remote) {
final byKey = <_MergeKey, FoodEntry>{};
for (final dayLog in [local, remote]) {
for (final entries in dayLog.values) {
for (final entry in entries) {
final key = _entryKey(entry);
final existing = byKey[key];
if (existing == null || _tombstoneWins(entry, existing)) {
byKey[key] = entry;
}
}
}
}
final merged = <String, List<FoodEntry>>{};
for (final entry in byKey.values) {
final dateKey = entry.time.length >= 10
? entry.time.substring(0, 10)
: entry.time;
merged.putIfAbsent(dateKey, () => []).add(entry);
}
for (final entries in merged.values) {
entries.sort((a, b) => a.time.compareTo(b.time));
}
return merged;
}

View File

@ -0,0 +1,144 @@
/// Cross-device log sync orchestration for the diet_guard companion app.
///
/// Pulls every other device's pushed log from GitHub-backed dumb storage
/// ([GitHubClient]), merges with the local log ([mergeLogs]), rebuilds the
/// food bank, and pushes this device's own merged log back up. A **new**
/// implementation, not a port of `~/todo`'s CRDT-based sync (this app has no
/// CRDT layer) -- a Dart twin of the same pull-merge-rebuild-push sequence
/// as `diet_guard/_sync.py`, plus one phone-specific step: a pulled copy of
/// an entry never carries `imagePath` (stripped before push, meaningless on
/// another device), so it must not null out a local photo attachment for
/// the same `id` (plan decision 10).
library;
import 'dart:convert';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/github_client.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/sync_merge.dart';
const _devicesDir = 'devices';
/// This device's id in the `devices/<id>/food_log.json` layout. The PC
/// pushes under `pc` (`SYNC_DEVICE_ID` in `diet_guard/_constants.py`); the
/// phone is the only other device in this design.
const phoneDeviceId = 'phone';
String _deviceLogPath(String deviceId) => '$_devicesDir/$deviceId/food_log.json';
/// Runs one full sync tick: pull, merge, preserve photos, persist, push.
///
/// Returns the merged log as it now sits on disk locally. Propagates any
/// [GitHubApiException] from the client for the caller (auto-sync / the
/// manual "Sync now" action) to decide how to report.
Future<DayLog> runSync(GitHubClient client) async {
final logService = LogStorageService.instance;
final local = await logService.readLog();
final localImagePaths = _imagePathsById(local);
var merged = local;
for (final deviceId in await client.listEntryNames(_devicesDir)) {
if (deviceId == phoneDeviceId) continue;
final text = await client.getFileText(_deviceLogPath(deviceId));
if (text == null) continue;
final remoteLog = _decodeRemoteLog(text);
if (remoteLog == null) continue;
merged = mergeLogs(merged, remoteLog);
}
merged = _preserveLocalImagePaths(merged, localImagePaths);
await logService.writeLog(merged);
await FoodBankService.instance.rebuildAndPersist(merged);
await client.putFileText(
_deviceLogPath(phoneDeviceId),
_encodeForPush(merged),
sha: await _ownFileSha(client),
message: 'diet_guard_app sync',
);
return merged;
}
/// Returns this device's current `food_log.json` sha if it has pushed
/// before, so [GitHubClient.putFileText] updates rather than creates.
Future<String?> _ownFileSha(GitHubClient client) async {
final files = await client.listDirectory('$_devicesDir/$phoneDeviceId');
for (final file in files) {
if (file.name == 'food_log.json') return file.sha;
}
return null;
}
Map<String, String> _imagePathsById(DayLog log) {
final result = <String, String>{};
for (final entries in log.values) {
for (final entry in entries) {
final id = entry.id;
final imagePath = entry.imagePath;
if (id != null && imagePath != null) result[id] = imagePath;
}
}
return result;
}
DayLog _preserveLocalImagePaths(
DayLog log,
Map<String, String> imagePathsById,
) {
if (imagePathsById.isEmpty) return log;
return {
for (final mapEntry in log.entries)
mapEntry.key: [
for (final entry in mapEntry.value)
_withPreservedImagePath(entry, imagePathsById),
],
};
}
FoodEntry _withPreservedImagePath(
FoodEntry entry,
Map<String, String> imagePathsById,
) {
if (entry.imagePath != null) return entry;
final preserved = imagePathsById[entry.id];
if (preserved == null) return entry;
return entry.copyWithImagePath(preserved);
}
/// Parses a device's pushed log, mirroring `_sync._pull_remote_logs`'s
/// tolerance for a corrupt/truncated push: an unparsable file is skipped
/// (returns null) rather than aborting the whole sync tick.
DayLog? _decodeRemoteLog(String text) {
Object? decoded;
try {
decoded = jsonDecode(text);
} on FormatException {
return null;
}
if (decoded is! Map) return null;
final result = <String, List<FoodEntry>>{};
for (final mapEntry in decoded.entries) {
final key = mapEntry.key;
final value = mapEntry.value;
if (key is! String || value is! List) continue;
result[key] = value
.whereType<Map<dynamic, dynamic>>()
.map((m) => FoodEntry.fromJson(m.cast<String, dynamic>()))
.toList();
}
return result;
}
/// Serializes the full merged log for push via [FoodEntry.toSyncJson],
/// which excludes [FoodEntry.imagePath] (phone-local only) and `hmac` (the
/// PC re-signs every persisted entry on its own next sync tick regardless).
String _encodeForPush(DayLog log) {
final encoded = <String, Object?>{
for (final mapEntry in log.entries)
mapEntry.key: mapEntry.value.map((e) => e.toSyncJson()).toList(),
};
return jsonEncode(encoded);
}

View File

@ -0,0 +1,121 @@
/// Locally-stored GitHub sync configuration, ported from `~/todo`'s
/// `sync/sync_settings.dart` -- with the OAuth device-flow fields dropped:
/// the phone leans on a pasted PAT instead (the plan's call to pick
/// "whichever is less code", and pasting is strictly less code here).
library;
import 'package:flutter/services.dart' show PlatformException;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// The GitHub token is kept in the OS keystore (Android Keystore / libsecret)
/// via [flutter_secure_storage]; only the non-secret owner/repo live in
/// `SharedPreferences`. Older builds stored the token in plaintext prefs;
/// [load]/[save] migrate it transparently and never drop the plaintext copy
/// until a secure write is confirmed (so we degrade to -- never below -- the
/// old behaviour when no secret service is available).
class SyncSettings {
/// Creates a [SyncSettings] from its owner/repo/token.
const SyncSettings({
required this.owner,
required this.repo,
required this.token,
});
/// The repo owner/org (e.g. `"kuhyx"`).
final String owner;
/// The repo name (e.g. `"diet-guard-sync"`).
final String repo;
/// A GitHub PAT with contents read/write on [owner]/[repo].
final String token;
/// True when enough is set to attempt a sync.
bool get isConfigured =>
owner.isNotEmpty && repo.isNotEmpty && token.isNotEmpty;
static const _kOwner = 'sync.owner';
static const _kRepo = 'sync.repo';
// Legacy plaintext location for the token; read-only now and removed once
// the token has been migrated into secure storage.
static const _kToken = 'sync.token';
/// Key for the token inside the OS keystore.
static const _secureToken = 'sync.token';
/// Default options keep us off the deprecated `encryptedSharedPreferences`
/// path on Android and use libsecret on Linux.
static const _secure = FlutterSecureStorage();
/// Loads settings, defaulting the owner/repo to `kuhyx/diet-guard-sync`
/// (matching the PC's `SYNC_REPO_OWNER`/`SYNC_REPO_NAME` constants) so a
/// fresh install needs only a pasted PAT.
static Future<SyncSettings> load() async {
final prefs = await SharedPreferences.getInstance();
return SyncSettings(
owner: prefs.getString(_kOwner) ?? 'kuhyx',
repo: prefs.getString(_kRepo) ?? 'diet-guard-sync',
token: await _loadToken(prefs),
);
}
/// Reads the token, preferring the keystore and falling back to the legacy
/// plaintext value. A legacy value is migrated into the keystore on read,
/// but only dropped from prefs once that secure write succeeds.
static Future<String> _loadToken(SharedPreferences prefs) async {
String? secure;
try {
secure = await _secure.read(key: _secureToken);
} on PlatformException {
// No secret service available -- fall back to the legacy plaintext copy.
secure = null;
}
if (secure != null && secure.isNotEmpty) return secure;
final legacy = prefs.getString(_kToken) ?? '';
if (legacy.isNotEmpty && await _writeSecureToken(legacy)) {
await prefs.remove(_kToken);
}
return legacy;
}
/// Persists [owner]/[repo] to prefs and [token] to the keystore (or
/// plaintext prefs as a fallback -- see [_loadToken]).
Future<void> save() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kOwner, owner);
await prefs.setString(_kRepo, repo);
// Confirm-before-delete: only remove the plaintext copy once the keystore
// write succeeds; otherwise keep persisting it to prefs as before.
if (await _writeSecureToken(token)) {
await prefs.remove(_kToken);
} else {
await prefs.setString(_kToken, token);
}
}
/// Writes [token] to the keystore (deleting the entry when empty). Returns
/// false if the platform secret service is unavailable.
static Future<bool> _writeSecureToken(String token) async {
try {
if (token.isEmpty) {
await _secure.delete(key: _secureToken);
} else {
await _secure.write(key: _secureToken, value: token);
}
return true;
} on PlatformException {
return false;
}
}
/// Returns a copy of this with only the given fields replaced.
SyncSettings copyWith({String? owner, String? repo, String? token}) {
return SyncSettings(
owner: owner ?? this.owner,
repo: repo ?? this.repo,
token: token ?? this.token,
);
}
}

1
app/linux/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
flutter/ephemeral

128
app/linux/CMakeLists.txt Normal file
View File

@ -0,0 +1,128 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "diet_guard_app")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.kuhy.diet_guard_app")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(SET CMP0063 NEW)
# Load bundled libraries from the lib/ directory relative to the binary.
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
# Root filesystem for cross-building.
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
endif()
# Define build configuration options.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
# the default top-level location.
set_target_properties(${BINARY_NAME}
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
)
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# By default, "installing" just makes a relocatable bundle in the build
# directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
# Start with a clean build bundle directory every time.
install(CODE "
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
" COMPONENT Runtime)
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
install(FILES "${bundled_library}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endforeach(bundled_library)
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()

View File

@ -0,0 +1,88 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.10)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
# which isn't available in 3.10.
function(list_prepend LIST_NAME PREFIX)
set(NEW_LIST "")
foreach(element ${${LIST_NAME}})
list(APPEND NEW_LIST "${PREFIX}${element}")
endforeach(element)
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
endfunction()
# === Flutter Library ===
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"fl_basic_message_channel.h"
"fl_binary_codec.h"
"fl_binary_messenger.h"
"fl_dart_project.h"
"fl_engine.h"
"fl_json_message_codec.h"
"fl_json_method_codec.h"
"fl_message_codec.h"
"fl_method_call.h"
"fl_method_channel.h"
"fl_method_codec.h"
"fl_method_response.h"
"fl_plugin_registrar.h"
"fl_plugin_registry.h"
"fl_standard_message_codec.h"
"fl_standard_method_codec.h"
"fl_string_codec.h"
"fl_value.h"
"fl_view.h"
"flutter_linux.h"
)
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
target_link_libraries(flutter INTERFACE
PkgConfig::GTK
PkgConfig::GLIB
PkgConfig::GIO
)
add_dependencies(flutter flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CMAKE_CURRENT_BINARY_DIR}/_phony_
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
)

View File

@ -0,0 +1,19 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
}

View File

@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View File

@ -0,0 +1,26 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

View File

@ -0,0 +1,26 @@
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# Define the application target. To change its name, change BINARY_NAME in the
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
# work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add preprocessor definitions for the application ID.
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Add dependency libraries. Add any application-specific dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")

6
app/linux/runner/main.cc Normal file
View File

@ -0,0 +1,6 @@
#include "my_application.h"
int main(int argc, char** argv) {
g_autoptr(MyApplication) app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv);
}

View File

@ -0,0 +1,148 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
#include "flutter/generated_plugin_registrant.h"
struct _MyApplication {
GtkApplication parent_instance;
char** dart_entrypoint_arguments;
};
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
// Called when first Flutter frame received.
static void first_frame_cb(MyApplication* self, FlView* view) {
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
}
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application);
GtkWindow* window =
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
// Use a header bar when running in GNOME as this is the common style used
// by applications and is the setup most users will be using (e.g. Ubuntu
// desktop).
// If running on X and not using GNOME then just use a traditional title bar
// in case the window manager does more exotic layout, e.g. tiling.
// If running on Wayland assume the header bar will work (may need changing
// if future cases occur).
gboolean use_header_bar = TRUE;
#ifdef GDK_WINDOWING_X11
GdkScreen* screen = gtk_window_get_screen(window);
if (GDK_IS_X11_SCREEN(screen)) {
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
use_header_bar = FALSE;
}
}
#endif
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "diet_guard_app");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "diet_guard_app");
}
gtk_window_set_default_size(window, 1280, 720);
g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments(
project, self->dart_entrypoint_arguments);
FlView* view = fl_view_new(project);
GdkRGBA background_color;
// Background defaults to black, override it here if necessary, e.g. #00000000
// for transparent.
gdk_rgba_parse(&background_color, "#000000");
fl_view_set_background_color(view, &background_color);
gtk_widget_show(GTK_WIDGET(view));
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
// Show the window when Flutter renders.
// Requires the view to be realized so we can start rendering.
g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
self);
gtk_widget_realize(GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_widget_grab_focus(GTK_WIDGET(view));
}
// Implements GApplication::local_command_line.
static gboolean my_application_local_command_line(GApplication* application,
gchar*** arguments,
int* exit_status) {
MyApplication* self = MY_APPLICATION(application);
// Strip out the first argument as it is the binary name.
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
g_autoptr(GError) error = nullptr;
if (!g_application_register(application, nullptr, &error)) {
g_warning("Failed to register: %s", error->message);
*exit_status = 1;
return TRUE;
}
g_application_activate(application);
*exit_status = 0;
return TRUE;
}
// Implements GApplication::startup.
static void my_application_startup(GApplication* application) {
// MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application startup.
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
}
// Implements GApplication::shutdown.
static void my_application_shutdown(GApplication* application) {
// MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application shutdown.
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
}
// Implements GObject::dispose.
static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
}
static void my_application_class_init(MyApplicationClass* klass) {
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
G_APPLICATION_CLASS(klass)->local_command_line =
my_application_local_command_line;
G_APPLICATION_CLASS(klass)->startup = my_application_startup;
G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
}
static void my_application_init(MyApplication* self) {}
MyApplication* my_application_new() {
// Set the program name to the application ID, which helps various systems
// like GTK and desktop environments map this running application to its
// corresponding .desktop file. This ensures better integration by allowing
// the application to be recognized beyond its binary name.
g_set_prgname(APPLICATION_ID);
return MY_APPLICATION(g_object_new(my_application_get_type(),
"application-id", APPLICATION_ID, "flags",
G_APPLICATION_NON_UNIQUE, nullptr));
}

View File

@ -0,0 +1,21 @@
#ifndef FLUTTER_MY_APPLICATION_H_
#define FLUTTER_MY_APPLICATION_H_
#include <gtk/gtk.h>
G_DECLARE_FINAL_TYPE(MyApplication,
my_application,
MY,
APPLICATION,
GtkApplication)
/**
* my_application_new:
*
* Creates a new Flutter-based application.
*
* Returns: a new #MyApplication.
*/
MyApplication* my_application_new();
#endif // FLUTTER_MY_APPLICATION_H_

View File

@ -89,6 +89,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
@ -142,6 +158,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev"
source: hosted
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
url: "https://pub.dev"
source: hosted
version: "3.0.1"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
url: "https://pub.dev"
source: hosted
version: "4.2.2"
flutter_test:
dependency: "direct dev"
description: flutter
@ -161,7 +225,7 @@ packages:
source: hosted
version: "2.0.2"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@ -424,6 +488,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
url: "https://pub.dev"
source: hosted
version: "2.4.26"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
@ -525,6 +645,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
url: "https://pub.dev"
source: hosted
version: "6.3.0"
xdg_directories:
dependency: transitive
description:

View File

@ -10,9 +10,12 @@ environment:
dependencies:
flutter:
sdk: flutter
flutter_secure_storage: ^10.3.1
http: ^1.6.0
image_picker: ^1.1.2
path: ^1.9.1
path_provider: ^2.1.5
shared_preferences: ^2.5.5
uuid: ^4.5.3
dev_dependencies:

View File

@ -0,0 +1,52 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// In-memory fake for the `flutter_secure_storage` platform channel so tests
/// never touch the real OS keystore. Install it from a test (or `setUp`) and
/// it auto-removes on tear down.
///
/// Pass [throwing] to simulate a host with no secret service: every call
/// raises a [PlatformException], which exercises the plaintext-fallback
/// paths in [SyncSettings].
void installFakeSecureStorage({
Map<String, String>? initial,
bool throwing = false,
}) {
const channel = MethodChannel(
'plugins.it_nomads.com/flutter_secure_storage',
);
final store = <String, String>{...?initial};
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (call) async {
if (throwing) {
throw PlatformException(code: 'unavailable');
}
final args = (call.arguments as Map?) ?? const <Object?, Object?>{};
final key = args['key'] as String?;
switch (call.method) {
case 'read':
return store[key];
case 'write':
store[key!] = args['value'] as String;
return null;
case 'delete':
store.remove(key);
return null;
case 'containsKey':
return store.containsKey(key);
case 'readAll':
return Map<String, String>.from(store);
case 'deleteAll':
store.clear();
return null;
default:
return null;
}
});
addTearDown(() {
messenger.setMockMethodCallHandler(channel, null);
});
}

View File

@ -0,0 +1,131 @@
// Covers LogMealScreen's auto-sync: triggered on launch and on every
// AppLifecycleState change, best-effort/silent regardless of outcome.
import 'dart:io';
import 'package:diet_guard_app/screens/log_meal_screen.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../fake_secure_storage.dart';
void main() {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_autosync_');
LogStorageService.resetForTesting(testDir: tempDir);
FoodBankService.resetForTesting(testDir: tempDir);
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
await tempDir.delete(recursive: true);
});
Future<void> settle(WidgetTester tester) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
testWidgets(
'does not push when sync is unconfigured (defaults to off)',
(tester) async {
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
var puts = 0;
final mock = MockClient((req) async {
if (req.method == 'PUT') puts++;
return http.Response('', 404);
});
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(home: LogMealScreen(httpClient: mock)),
);
await settle(tester);
expect(puts, 0);
});
},
);
testWidgets('pushes on launch when sync is configured', (tester) async {
SharedPreferences.setMockInitialValues({
'sync.owner': 'o',
'sync.repo': 'r',
});
installFakeSecureStorage(initial: {'sync.token': 't'});
var puts = 0;
final mock = MockClient((req) async {
if (req.method == 'PUT') puts++;
return http.Response('', 404);
});
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(home: LogMealScreen(httpClient: mock)),
);
await settle(tester);
expect(puts, 1);
});
});
testWidgets('pushes again when the app is paused', (tester) async {
SharedPreferences.setMockInitialValues({
'sync.owner': 'o',
'sync.repo': 'r',
});
installFakeSecureStorage(initial: {'sync.token': 't'});
var puts = 0;
final mock = MockClient((req) async {
if (req.method == 'PUT') puts++;
return http.Response('', 404);
});
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(home: LogMealScreen(httpClient: mock)),
);
await settle(tester);
expect(puts, 1); // launch
// Flutter's AppLifecycleListener enforces a strict transition graph
// (resumed -> inactive -> hidden -> paused -> ...); jumping straight
// from resumed to paused is the one direct transition it allows.
WidgetsBinding.instance.handleAppLifecycleStateChanged(
AppLifecycleState.paused,
);
await settle(tester);
expect(puts, 2);
});
});
testWidgets('swallows a sync failure without crashing the screen', (
tester,
) async {
SharedPreferences.setMockInitialValues({
'sync.owner': 'o',
'sync.repo': 'r',
});
installFakeSecureStorage(initial: {'sync.token': 't'});
final mock = MockClient((_) async => http.Response('boom', 500));
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(home: LogMealScreen(httpClient: mock)),
);
await settle(tester);
expect(find.byType(LogMealScreen), findsOneWidget);
expect(tester.takeException(), isNull);
});
});
}

View File

@ -4,6 +4,7 @@ import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/log_meal_screen.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/screens/photo_viewer_screen.dart';
import 'package:diet_guard_app/screens/settings_screen.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/photo_attach_service.dart';
@ -143,6 +144,24 @@ void main() {
});
});
testWidgets('the settings icon navigates to SettingsScreen', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
// SettingsScreen briefly shows a perpetually-animating
// CircularProgressIndicator while its settings load; pumpAndSettle
// never settles against that, so pump explicit frames instead (see
// history_screen_test.dart's note on the same pitfall).
await tester.tap(find.byIcon(Icons.settings));
await tester.pump();
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pump();
expect(find.byType(SettingsScreen), findsOneWidget);
});
});
testWidgets('logging a manually-typed meal persists it as source manual', (
tester,
) async {

View File

@ -0,0 +1,148 @@
import 'dart:io';
import 'package:diet_guard_app/screens/settings_screen.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../fake_secure_storage.dart';
void main() {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_settings_');
LogStorageService.resetForTesting(testDir: tempDir);
FoodBankService.resetForTesting(testDir: tempDir);
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
await tempDir.delete(recursive: true);
});
// SettingsScreen loads its settings via a fire-and-forget Future in
// initState that Flutter's frame scheduler does not track -- same pitfall
// as HistoryScreen/LogMealScreen.
Future<void> settle(WidgetTester tester) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
testWidgets('shows the kuhyx/diet-guard-sync defaults on a fresh install', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
expect(find.widgetWithText(TextField, 'kuhyx'), findsOneWidget);
expect(find.widgetWithText(TextField, 'diet-guard-sync'), findsOneWidget);
});
});
testWidgets('Save persists the entered token', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
await tester.enterText(
find.widgetWithText(TextField, 'Personal access token'),
'my-pat',
);
await tester.tap(find.widgetWithText(ElevatedButton, 'Save'));
await settle(tester);
expect(find.text('Saved.'), findsOneWidget);
});
});
testWidgets('Test connection reports success', (tester) async {
final mock = MockClient(
(_) async => http.Response('{}', 200),
);
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(home: SettingsScreen(httpClient: mock)),
);
await settle(tester);
await tester.tap(find.widgetWithText(OutlinedButton, 'Test connection'));
await settle(tester);
expect(find.text('Connection OK.'), findsOneWidget);
});
});
testWidgets('Test connection reports failure', (tester) async {
final mock = MockClient((_) async => http.Response('', 403));
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(home: SettingsScreen(httpClient: mock)),
);
await settle(tester);
await tester.tap(find.widgetWithText(OutlinedButton, 'Test connection'));
await settle(tester);
expect(find.text('Connection failed.'), findsOneWidget);
});
});
testWidgets('Sync now runs a sync tick and reports success', (
tester,
) async {
final mock = MockClient((req) async {
if (req.method == 'PUT') return http.Response('{}', 200);
return http.Response('', 404);
});
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(home: SettingsScreen(httpClient: mock)),
);
await settle(tester);
await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now'));
await settle(tester);
expect(find.text('Synced.'), findsOneWidget);
});
});
testWidgets('Test connection reports a network exception', (tester) async {
final mock = MockClient((_) async => throw const FormatException('no net'));
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(home: SettingsScreen(httpClient: mock)),
);
await settle(tester);
await tester.tap(find.widgetWithText(OutlinedButton, 'Test connection'));
await settle(tester);
expect(find.textContaining('Connection failed:'), findsOneWidget);
});
});
testWidgets('Sync now reports a GitHub error', (tester) async {
final mock = MockClient((_) async => http.Response('boom', 500));
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(home: SettingsScreen(httpClient: mock)),
);
await settle(tester);
await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now'));
await settle(tester);
expect(find.textContaining('Sync failed:'), findsOneWidget);
});
});
}

View File

@ -0,0 +1,132 @@
import 'dart:convert';
import 'package:diet_guard_app/services/github_client.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
GitHubClient client(MockClient mock) =>
GitHubClient(owner: 'o', repo: 'r', token: 't', httpClient: mock);
test('listDirectory returns only files and ignores subdirectories', () async {
final mock = MockClient((req) async {
expect(req.headers['Authorization'], contains('t'));
return http.Response(
jsonEncode([
{'type': 'file', 'name': 'a.json', 'path': 'd/a.json', 'sha': 's1'},
{'type': 'dir', 'name': 'sub', 'path': 'd/sub', 'sha': 's2'},
]),
200,
);
});
final files = await client(mock).listDirectory('d');
expect(files, hasLength(1));
expect(files.single.name, 'a.json');
expect(files.single.sha, 's1');
});
test(
'listDirectory returns empty on 404 (directory not created yet)',
() async {
final files = await client(
MockClient((_) async => http.Response('', 404)),
).listDirectory('missing');
expect(files, isEmpty);
},
);
test('listEntryNames returns both file and directory names', () async {
final mock = MockClient(
(_) async => http.Response(
jsonEncode([
{'type': 'dir', 'name': 'pc', 'path': 'devices/pc', 'sha': 's1'},
{'type': 'dir', 'name': 'phone', 'path': 'devices/phone', 'sha': 's2'},
]),
200,
),
);
expect(await client(mock).listEntryNames('devices'), ['pc', 'phone']);
});
test('listEntryNames returns empty on 404', () async {
final files = await client(
MockClient((_) async => http.Response('', 404)),
).listEntryNames('missing');
expect(files, isEmpty);
});
test('getFileText base64-decodes content; null on 404', () async {
final encoded = base64.encode(utf8.encode('hello world'));
final ok = MockClient(
(_) async => http.Response(jsonEncode({'content': encoded}), 200),
);
expect(await client(ok).getFileText('f'), 'hello world');
final missing = MockClient((_) async => http.Response('', 404));
expect(await client(missing).getFileText('f'), isNull);
});
test(
'putFileText omits sha when creating, includes it when updating',
() async {
String? sentBody;
final mock = MockClient((req) async {
sentBody = req.body;
return http.Response('{}', 201);
});
await client(mock).putFileText('f', 'data');
expect(jsonDecode(sentBody!).containsKey('sha'), isFalse);
await client(mock).putFileText('f', 'data', sha: 'abc');
expect(jsonDecode(sentBody!)['sha'], 'abc');
},
);
test('deleteFile sends the sha', () async {
String? body;
final mock = MockClient((req) async {
body = req.body;
return http.Response('{}', 200);
});
await client(mock).deleteFile('f', 'sha123');
expect(jsonDecode(body!)['sha'], 'sha123');
});
test('canAccessRepo reflects the status code', () async {
expect(
await client(
MockClient((_) async => http.Response('{}', 200)),
).canAccessRepo(),
isTrue,
);
expect(
await client(
MockClient((_) async => http.Response('', 403)),
).canAccessRepo(),
isFalse,
);
});
test('throws GitHubApiException on a non-2xx that is not 404', () async {
final mock = MockClient((_) async => http.Response('boom', 500));
expect(
() => client(mock).getFileText('f'),
throwsA(isA<GitHubApiException>()),
);
});
test('GitHubApiException.toString includes status and message', () {
expect(
GitHubApiException(500, 'boom').toString(),
'GitHubApiException(500): boom',
);
});
test('creates a default http client when none is injected', () {
// No httpClient the constructor builds a real http.Client; just make
// sure that branch runs and the client closes cleanly (no request made).
final c = GitHubClient(owner: 'o', repo: 'r', token: 't');
addTearDown(c.close);
});
}

View File

@ -0,0 +1,170 @@
// Table-driven `mergeLogs()` tests mirroring `test_sync_merge.py` exactly
// (same cases, same expected outcomes), so both implementations are provably
// testing the same algorithm.
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/sync_merge.dart';
import 'package:flutter_test/flutter_test.dart';
FoodEntry _entry({
String? id = 'id-1',
String time = '2026-06-22T08:00:00',
String desc = 'oatmeal',
bool deleted = false,
}) => FoodEntry(
id: id,
time: time,
desc: desc,
grams: 200,
kcal: 300,
proteinG: 10,
carbsG: 50,
fatG: 5,
source: 'manual',
deleted: deleted,
);
void main() {
group('union by id', () {
test('disjoint logs union into one', () {
final a = {
'2026-06-22': [_entry(id: 'a', time: '2026-06-22T08:00:00')],
};
final b = {
'2026-06-22': [_entry(id: 'b', time: '2026-06-22T12:00:00')],
};
final merged = mergeLogs(a, b);
expect(merged['2026-06-22']!.map((e) => e.id).toSet(), {'a', 'b'});
});
test('same id in both logs is not duplicated', () {
final shared = _entry(id: 'shared');
final merged = mergeLogs({
'2026-06-22': [shared],
}, {'2026-06-22': [shared]});
expect(merged['2026-06-22'], hasLength(1));
});
test('legacy entries without id dedup by time and desc', () {
final legacyA = _entry(
id: null,
time: '2026-06-20T08:00:00',
desc: 'toast',
);
final legacyB = _entry(
id: null,
time: '2026-06-20T08:00:00',
desc: 'toast',
);
final merged = mergeLogs({
'2026-06-20': [legacyA],
}, {'2026-06-20': [legacyB]});
expect(merged['2026-06-20'], hasLength(1));
});
test('legacy and id entries with different keys both survive', () {
final legacy = _entry(
id: null,
time: '2026-06-20T08:00:00',
desc: 'toast',
);
final withId = _entry(id: 'x', time: '2026-06-20T09:00:00', desc: 'eggs');
final merged = mergeLogs({
'2026-06-20': [legacy],
}, {'2026-06-20': [withId]});
expect(merged['2026-06-20'], hasLength(2));
});
});
group('tombstone wins', () {
test('tombstone beats a non-deleted copy either order', () {
final normal = _entry(id: 'x');
final tombstoned = _entry(id: 'x', deleted: true);
final forward = mergeLogs({
'2026-06-22': [normal],
}, {'2026-06-22': [tombstoned]});
final backward = mergeLogs({
'2026-06-22': [tombstoned],
}, {'2026-06-22': [normal]});
expect(forward['2026-06-22']!.single.deleted, isTrue);
expect(backward['2026-06-22']!.single.deleted, isTrue);
});
test('two tombstoned copies stay tombstoned', () {
final tombstoned = _entry(id: 'x', deleted: true);
final merged = mergeLogs({
'2026-06-22': [tombstoned],
}, {'2026-06-22': [_entry(id: 'x', deleted: true)]});
expect(merged['2026-06-22']!.single.deleted, isTrue);
});
});
group('rebucketing and ordering', () {
test(
"entry is filed under its own time's date, not the arrival bucket",
() {
final misfiled = _entry(id: 'x', time: '2026-06-21T23:00:00');
final merged = mergeLogs({'2026-06-22': [misfiled]}, {});
expect(merged.keys, ['2026-06-21']);
expect(merged['2026-06-21']!.single.id, 'x');
},
);
test(
'an entry with a time shorter than a date key buckets under the '
'raw time instead of crashing',
() {
// Dart's substring throws past the string length, unlike Python's
// forgiving slice -- this only matters for malformed/legacy data.
final short = _entry(id: 'x', time: '2026');
final merged = mergeLogs({'2026-06-22': [short]}, {});
expect(merged.keys, ['2026']);
},
);
test("a day's entries are sorted oldest-first", () {
final late = _entry(id: 'late', time: '2026-06-22T20:00:00');
final early = _entry(id: 'early', time: '2026-06-22T08:00:00');
final merged = mergeLogs({
'2026-06-22': [late],
}, {'2026-06-22': [early]});
expect(merged['2026-06-22']!.map((e) => e.id).toList(), [
'early',
'late',
]);
});
});
group('algebraic properties', () {
test('merge is commutative', () {
final a = {'2026-06-22': [_entry(id: 'a')]};
final b = {
'2026-06-22': [_entry(id: 'b', time: '2026-06-22T09:00:00')],
};
final ab = mergeLogs(a, b);
final ba = mergeLogs(b, a);
expect(
ab['2026-06-22']!.map((e) => e.id).toList(),
ba['2026-06-22']!.map((e) => e.id).toList(),
);
});
test('merge is idempotent', () {
final canonical = {'2026-06-22': [_entry(id: 'a')]};
final merged = mergeLogs(canonical, canonical);
expect(merged['2026-06-22']!.map((e) => e.id).toList(), ['a']);
});
test('merging with an empty log is a no-op', () {
final log = {'2026-06-22': [_entry(id: 'a')]};
expect(mergeLogs(log, {}).keys, log.keys);
expect(mergeLogs({}, log).keys, log.keys);
});
test('merging two empty logs is empty', () {
expect(mergeLogs({}, {}), isEmpty);
});
});
}

View File

@ -0,0 +1,252 @@
// Mirrors `test_sync.py`'s `TestRunSync` cases (own-id-skip, no-prior-push,
// non-object payload, corrupt JSON, remote merge, food bank rebuild), plus
// one Dart-specific case for the phone's `imagePath`-preserve-by-id step
// (plan decision 10) that has no PC-side equivalent.
import 'dart:convert';
import 'dart:io';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/models/nutrition.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/github_client.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/sync_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
const _manual = Nutrition(
kcal: 200,
proteinG: 10,
carbsG: 20,
fatG: 5,
grams: 100,
source: 'manual',
);
/// A tiny in-memory stand-in for the GitHub Contents API, scoped to exactly
/// the calls [runSync] makes: list `devices`, list a single device's own
/// directory (for its file's sha), get a device's file text, and put this
/// device's file text.
class _FakeGitHub {
_FakeGitHub({this.deviceDirs = const [], Map<String, String>? files})
: files = {...?files};
final List<String> deviceDirs;
final Map<String, String> files;
/// Every `devices/<id>/food_log.json` path this fake actually served a
/// file-content GET for (i.e. a real `getFileText` pull, not a listing).
final List<String> fileGets = [];
/// Every PUT this fake received, decoded.
final List<Map<String, dynamic>> puts = [];
GitHubClient buildClient() => GitHubClient(
owner: 'o',
repo: 'r',
token: 't',
httpClient: MockClient(_handle),
);
Future<http.Response> _handle(http.Request req) async {
final path = req.url.path.replaceFirst('/repos/o/r/contents/', '');
if (req.method == 'PUT') {
puts.add(jsonDecode(req.body) as Map<String, dynamic>);
return http.Response('{}', 200);
}
if (path == 'devices') {
return http.Response(
jsonEncode([
for (final d in deviceDirs)
{'type': 'dir', 'name': d, 'path': 'devices/$d', 'sha': 'd-$d'},
]),
200,
);
}
final segments = path.split('/');
if (segments.length == 2 && segments[0] == 'devices') {
final deviceId = segments[1];
final filePath = 'devices/$deviceId/food_log.json';
if (!files.containsKey(filePath)) return http.Response('', 404);
return http.Response(
jsonEncode([
{
'type': 'file',
'name': 'food_log.json',
'path': filePath,
'sha': 'f-$deviceId',
},
]),
200,
);
}
if (!files.containsKey(path)) return http.Response('', 404);
fileGets.add(path);
final content = base64.encode(utf8.encode(files[path]!));
return http.Response(jsonEncode({'content': content}), 200);
}
}
void main() {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_sync_test_');
LogStorageService.resetForTesting(testDir: tempDir);
FoodBankService.resetForTesting(testDir: tempDir);
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
await tempDir.delete(recursive: true);
});
test('pushes the local log when no other devices have synced', () async {
await LogStorageService.instance.logMeal('oatmeal', _manual);
final fake = _FakeGitHub();
final merged = await runSync(fake.buildClient());
expect(merged.values.expand((e) => e).length, 1);
expect(fake.puts, hasLength(1));
});
test("skips its own device id ('phone') when listing", () async {
final fake = _FakeGitHub(
deviceDirs: const ['pc', 'phone'],
files: const {'devices/pc/food_log.json': '{}'},
);
await runSync(fake.buildClient());
expect(fake.fileGets, ['devices/pc/food_log.json']);
});
test('skips a device with no pushed file yet', () async {
final fake = _FakeGitHub(deviceDirs: const ['pc']);
final merged = await runSync(fake.buildClient());
expect(merged, isEmpty);
});
test('ignores a device whose pushed file is not a JSON object', () async {
final fake = _FakeGitHub(
deviceDirs: const ['pc'],
files: const {'devices/pc/food_log.json': '[]'},
);
final merged = await runSync(fake.buildClient());
expect(merged, isEmpty);
});
test('skips a device whose pushed file is corrupt json', () async {
final fake = _FakeGitHub(
deviceDirs: const ['pc'],
files: const {'devices/pc/food_log.json': '{not valid json'},
);
final merged = await runSync(fake.buildClient());
expect(merged, isEmpty);
});
test("merges in a remote device's entries", () async {
final remoteLog = jsonEncode({
'2026-06-22': [
{
'id': 'pc-1',
'time': '2026-06-22T09:00:00+02:00',
'desc': 'pc meal',
'kcal': 400.0,
'protein_g': 20.0,
'carbs_g': 40.0,
'fat_g': 10.0,
'grams': 300.0,
'source': 'manual',
},
],
});
final fake = _FakeGitHub(
deviceDirs: const ['pc'],
files: {'devices/pc/food_log.json': remoteLog},
);
final merged = await runSync(fake.buildClient());
final descs = merged.values.expand((e) => e).map((e) => e.desc).toSet();
expect(descs, contains('pc meal'));
});
test('rebuilds the food bank after merge', () async {
await LogStorageService.instance.logMeal('oatmeal', _manual);
final fake = _FakeGitHub();
await runSync(fake.buildClient());
final bank = await FoodBankService.instance.readBank();
expect(bank.containsKey('oatmeal'), isTrue);
});
test('pushes a payload without imagePath or hmac', () async {
await LogStorageService.instance.logMeal(
'oatmeal',
_manual,
imagePath: '/local/photo.jpg',
);
final fake = _FakeGitHub();
await runSync(fake.buildClient());
final pushed = fake.puts.single;
final pushedText = utf8.decode(base64.decode(pushed['content'] as String));
expect(pushedText, isNot(contains('imagePath')));
expect(pushedText, isNot(contains('hmac')));
});
test("reuses this device's existing sha when it has pushed before", () async {
final fake = _FakeGitHub(
files: const {'devices/phone/food_log.json': '{}'},
);
await runSync(fake.buildClient());
expect(fake.puts.single['sha'], 'f-phone');
});
test(
'preserves a local imagePath even when a remote tombstone wins the merge',
() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [
const FoodEntry(
id: 'x',
time: '2026-06-22T08:00:00',
desc: 'photo meal',
grams: 100,
kcal: 200,
proteinG: 10,
carbsG: 20,
fatG: 5,
source: 'manual',
imagePath: '/local/photo.jpg',
),
],
});
final remoteLog = jsonEncode({
'2026-06-22': [
{
'id': 'x',
'time': '2026-06-22T08:00:00',
'desc': 'photo meal',
'kcal': 200.0,
'protein_g': 10.0,
'carbs_g': 20.0,
'fat_g': 5.0,
'grams': 100.0,
'source': 'manual',
'deleted': true,
},
],
});
final fake = _FakeGitHub(
deviceDirs: const ['pc'],
files: {'devices/pc/food_log.json': remoteLog},
);
final merged = await runSync(fake.buildClient());
final entry = merged.values.expand((e) => e).single;
expect(entry.deleted, isTrue);
expect(entry.imagePath, '/local/photo.jpg');
},
);
}

View File

@ -0,0 +1,121 @@
import 'package:diet_guard_app/services/sync_settings.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../fake_secure_storage.dart';
void main() {
// installFakeSecureStorage touches the test binary messenger, which needs
// the binding up first (widget tests get this for free via testWidgets).
TestWidgetsFlutterBinding.ensureInitialized();
test(
'load returns the kuhyx/diet-guard-sync defaults on a fresh install',
() async {
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
final s = await SyncSettings.load();
expect(s.owner, 'kuhyx');
expect(s.repo, 'diet-guard-sync');
expect(s.token, '');
},
);
test('save stores the token in the keystore, not in prefs', () async {
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
await const SyncSettings(owner: 'me', repo: 'notes', token: 'tok').save();
// Token must not linger in plaintext prefs once secured.
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('sync.token'), isNull);
final s = await SyncSettings.load();
expect(s.owner, 'me');
expect(s.repo, 'notes');
expect(s.token, 'tok');
});
test('load reads the token straight from the keystore', () async {
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage(initial: {'sync.token': 'fromKeystore'});
final s = await SyncSettings.load();
expect(s.token, 'fromKeystore');
});
test('load migrates a legacy plaintext token into the keystore', () async {
SharedPreferences.setMockInitialValues({'sync.token': 'legacy'});
installFakeSecureStorage();
final s = await SyncSettings.load();
expect(s.token, 'legacy');
// The plaintext copy is dropped once the secure write succeeds, and the
// value now resolves from the keystore on the next load.
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('sync.token'), isNull);
final again = await SyncSettings.load();
expect(again.token, 'legacy');
});
test(
'load keeps the plaintext token when no secret service is available',
() async {
SharedPreferences.setMockInitialValues({'sync.token': 'plain'});
installFakeSecureStorage(throwing: true);
final s = await SyncSettings.load();
expect(s.token, 'plain');
// Never drop the only copy when the keystore write can't be confirmed.
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('sync.token'), 'plain');
},
);
test('save falls back to plaintext prefs when the keystore fails', () async {
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage(throwing: true);
await const SyncSettings(owner: 'o', repo: 'r', token: 'tok').save();
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('sync.token'), 'tok');
});
test('save with an empty token clears the keystore entry', () async {
// Seed a keystore token, then save an empty token: it must be deleted
// and no plaintext copy written.
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage(initial: {'sync.token': 'old'});
await const SyncSettings(owner: 'o', repo: 'r', token: '').save();
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('sync.token'), isNull);
final s = await SyncSettings.load();
expect(s.token, '');
});
test('isConfigured requires owner, repo and token', () {
expect(
const SyncSettings(owner: 'o', repo: 'r', token: 't').isConfigured,
isTrue,
);
expect(
const SyncSettings(owner: 'o', repo: 'r', token: '').isConfigured,
isFalse,
);
});
test('copyWith overrides only the given fields', () {
const base = SyncSettings(owner: 'o', repo: 'r', token: 't');
final next = base.copyWith(token: 'new');
expect(next.owner, 'o');
expect(next.repo, 'r');
expect(next.token, 'new');
// No-arg copy exercises the `?? this.x` fallback on every field.
final clone = base.copyWith();
expect(clone.owner, 'o');
expect(clone.repo, 'r');
expect(clone.token, 't');
});
}