mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 13:43:30 +02:00
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:
parent
e5b80fd610
commit
a82047502f
@ -15,7 +15,7 @@ migration:
|
||||
- platform: root
|
||||
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||
- platform: android
|
||||
- platform: linux
|
||||
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
180
app/lib/screens/settings_screen.dart
Normal file
180
app/lib/screens/settings_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
203
app/lib/services/github_client.dart
Normal file
203
app/lib/services/github_client.dart
Normal 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}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
69
app/lib/services/sync_merge.dart
Normal file
69
app/lib/services/sync_merge.dart
Normal 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;
|
||||
}
|
||||
144
app/lib/services/sync_service.dart
Normal file
144
app/lib/services/sync_service.dart
Normal 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);
|
||||
}
|
||||
121
app/lib/services/sync_settings.dart
Normal file
121
app/lib/services/sync_settings.dart
Normal 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
1
app/linux/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
flutter/ephemeral
|
||||
128
app/linux/CMakeLists.txt
Normal file
128
app/linux/CMakeLists.txt
Normal 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()
|
||||
88
app/linux/flutter/CMakeLists.txt
Normal file
88
app/linux/flutter/CMakeLists.txt
Normal 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}
|
||||
)
|
||||
19
app/linux/flutter/generated_plugin_registrant.cc
Normal file
19
app/linux/flutter/generated_plugin_registrant.cc
Normal 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);
|
||||
}
|
||||
15
app/linux/flutter/generated_plugin_registrant.h
Normal file
15
app/linux/flutter/generated_plugin_registrant.h
Normal 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_
|
||||
26
app/linux/flutter/generated_plugins.cmake
Normal file
26
app/linux/flutter/generated_plugins.cmake
Normal 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)
|
||||
26
app/linux/runner/CMakeLists.txt
Normal file
26
app/linux/runner/CMakeLists.txt
Normal 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
6
app/linux/runner/main.cc
Normal 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);
|
||||
}
|
||||
148
app/linux/runner/my_application.cc
Normal file
148
app/linux/runner/my_application.cc
Normal 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));
|
||||
}
|
||||
21
app/linux/runner/my_application.h
Normal file
21
app/linux/runner/my_application.h
Normal 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_
|
||||
130
app/pubspec.lock
130
app/pubspec.lock
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
52
app/test/fake_secure_storage.dart
Normal file
52
app/test/fake_secure_storage.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
131
app/test/screens/log_meal_screen_sync_test.dart
Normal file
131
app/test/screens/log_meal_screen_sync_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
148
app/test/screens/settings_screen_test.dart
Normal file
148
app/test/screens/settings_screen_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
132
app/test/services/github_client_test.dart
Normal file
132
app/test/services/github_client_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
170
app/test/services/sync_merge_test.dart
Normal file
170
app/test/services/sync_merge_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
252
app/test/services/sync_service_test.dart
Normal file
252
app/test/services/sync_service_test.dart
Normal 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');
|
||||
},
|
||||
);
|
||||
}
|
||||
121
app/test/services/sync_settings_test.dart
Normal file
121
app/test/services/sync_settings_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user