From 09cf5b726a7718c441a4dbdf23c45e81bd0d177d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 11 Jul 2026 23:54:03 +0200 Subject: [PATCH] app: use crdt_sync's shared GitHubClient; drop the local copy crdt_sync v0.3.0 adds canAccessRepo(), so the settings 'Test connection' button uses the shared client and the local lib/services/github_client.dart (+ its test) is deleted. flutter analyze clean, 299 tests pass. On-device foreground check deferred: the active night curfew disables the app (a phone-policy thing, not a code issue); build + install -r succeeded. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ --- app/lib/screens/settings_screen.dart | 3 +- app/lib/services/github_client.dart | 203 ---------------------- app/pubspec.lock | 6 +- app/pubspec.yaml | 2 +- app/test/services/github_client_test.dart | 137 --------------- 5 files changed, 5 insertions(+), 346 deletions(-) delete mode 100644 app/lib/services/github_client.dart delete mode 100644 app/test/services/github_client_test.dart diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index b89f4d9..8133a48 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -11,7 +11,6 @@ import 'dart:async'; import 'package:crdt_sync/crdt_sync.dart' as crdt_sync; import 'package:diet_guard_app/screens/log_meal_screen.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; -import 'package:diet_guard_app/services/github_client.dart'; import 'package:diet_guard_app/services/github_device_auth.dart'; import 'package:diet_guard_app/services/sync_service.dart'; import 'package:diet_guard_app/services/sync_settings.dart'; @@ -169,7 +168,7 @@ class _SettingsScreenState extends State { Future _testConnection() async { setState(() => _busy = true); final settings = _currentSettings(); - final client = GitHubClient( + final client = crdt_sync.GitHubClient( owner: settings.owner, repo: settings.repo, token: settings.token, diff --git a/app/lib/services/github_client.dart b/app/lib/services/github_client.dart deleted file mode 100644 index cf51961..0000000 --- a/app/lib/services/github_client.dart +++ /dev/null @@ -1,203 +0,0 @@ -/// 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. `"syncs"`). - 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 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> 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>() - .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//food_log.json` layout, where each device id is itself a - /// directory one level above its file. - Future> 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((e) => e['name'] as String) - .toList(); - } - - /// Fetches and UTF-8-decodes a file's contents. Returns null if absent. - Future 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; - // 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 putFileText( - String path, - String text, { - String? sha, - String? message, - }) async { - final body = { - '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 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 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}', - ); - } - } -} diff --git a/app/pubspec.lock b/app/pubspec.lock index 0e522de..e3e4a78 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -61,11 +61,11 @@ packages: dependency: "direct main" description: path: crdt_sync_dart - ref: "crdt_sync_dart-v0.1.0" - resolved-ref: "6b14e6d36d137d90851f00d1e282a2069c13d3a6" + ref: "crdt_sync_dart-v0.3.0" + resolved-ref: "5480e723354c31e2a38dd2757d5d2c75a04e9618" url: "https://github.com/kuhyx/utils" source: git - version: "0.1.0" + version: "0.3.0" cross_file: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index ca029cc..450a54f 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: crdt_sync: git: url: https://github.com/kuhyx/utils - ref: crdt_sync_dart-v0.1.0 + ref: crdt_sync_dart-v0.3.0 path: crdt_sync_dart crypto: ^3.0.6 flutter: diff --git a/app/test/services/github_client_test.dart b/app/test/services/github_client_test.dart deleted file mode 100644 index 76c204c..0000000 --- a/app/test/services/github_client_test.dart +++ /dev/null @@ -1,137 +0,0 @@ -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()), - ); - }); - - 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); - }); -}