app: use crdt_sync's shared GitHubClient; drop the local copy
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
This commit is contained in:
Krzysztof Rudnicki 2026-07-11 23:54:03 +02:00
parent 67bc98c7e0
commit 09cf5b726a
5 changed files with 5 additions and 346 deletions

View File

@ -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<SettingsScreen> {
Future<void> _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,

View File

@ -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<String, String> get _headers => {
'Authorization': 'Bearer $_token',
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'diet-guard-app-sync',
};
Uri _contentsUri(String path) =>
Uri.parse('$_apiBase/repos/$owner/$repo/contents/$path');
/// Lists the files directly under [dirPath]. Returns an empty list if the
/// directory does not exist yet (e.g. before the first sync).
Future<List<GitHubFile>> listDirectory(String dirPath) async {
final res = await _http.get(
_contentsUri(dirPath).replace(queryParameters: {'ref': branch}),
headers: _headers,
);
if (res.statusCode == 404) return [];
_ensureOk(res, 'list $dirPath');
final decoded = jsonDecode(res.body);
if (decoded is! List) return [];
return decoded
.cast<Map<String, dynamic>>()
.where((e) => e['type'] == 'file')
.map(
(e) => GitHubFile(
name: e['name'] as String,
path: e['path'] as String,
sha: e['sha'] as String,
),
)
.toList();
}
/// Lists the names of every entry (file *or* directory) directly under
/// [dirPath]. Returns an empty list if the directory does not exist yet.
///
/// Unlike [listDirectory] (files-only, for a flat layout), this also
/// surfaces subdirectory names -- needed for diet_guard's per-device
/// `devices/<id>/food_log.json` layout, where each device id is itself a
/// directory one level above its file.
Future<List<String>> listEntryNames(String dirPath) async {
final res = await _http.get(
_contentsUri(dirPath).replace(queryParameters: {'ref': branch}),
headers: _headers,
);
if (res.statusCode == 404) return [];
_ensureOk(res, 'list $dirPath');
final decoded = jsonDecode(res.body);
if (decoded is! List) return [];
return decoded
.cast<Map<String, dynamic>>()
.map((e) => e['name'] as String)
.toList();
}
/// Fetches and UTF-8-decodes a file's contents. Returns null if absent.
Future<String?> getFileText(String path) async {
final res = await _http.get(
_contentsUri(path).replace(queryParameters: {'ref': branch}),
headers: _headers,
);
if (res.statusCode == 404) return null;
_ensureOk(res, 'get $path');
final json = jsonDecode(res.body) as Map<String, dynamic>;
// GitHub base64-encodes file content, wrapping lines at 60 chars.
final raw = (json['content'] as String).replaceAll('\n', '');
return utf8.decode(base64.decode(raw));
}
/// Creates or updates [path] with [text]. Pass the current [sha] when
/// updating an existing file; omit it to create a new one.
Future<void> putFileText(
String path,
String text, {
String? sha,
String? message,
}) async {
final body = <String, dynamic>{
'message': message ?? 'sync: update $path',
'content': base64.encode(utf8.encode(text)),
'branch': branch,
'sha': ?sha,
};
final res = await _http.put(
_contentsUri(path),
headers: _headers,
body: jsonEncode(body),
);
_ensureOk(res, 'put $path');
}
/// Deletes the file at [path] (requires its current [sha]).
Future<void> deleteFile(String path, String sha, {String? message}) async {
final res = await _http.delete(
_contentsUri(path),
headers: _headers,
body: jsonEncode({
'message': message ?? 'sync: delete $path',
'sha': sha,
'branch': branch,
}),
);
_ensureOk(res, 'delete $path');
}
/// Cheap auth/connectivity probe used by the settings "Test connection"
/// button: succeeds only if the token can read the repo.
Future<bool> canAccessRepo() async {
final res = await _http.get(
Uri.parse('$_apiBase/repos/$owner/$repo'),
headers: _headers,
);
return res.statusCode == 200;
}
/// Closes the underlying HTTP client.
void close() => _http.close();
void _ensureOk(http.Response res, String action) {
if (res.statusCode < 200 || res.statusCode >= 300) {
throw GitHubApiException(
res.statusCode,
'Failed to $action: ${res.body}',
);
}
}
}

View File

@ -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:

View File

@ -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:

View File

@ -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<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);
});
}