mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
This commit is contained in:
parent
67bc98c7e0
commit
09cf5b726a
@ -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,
|
||||
|
||||
@ -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}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user