diet-guard/app/lib/services/github_client.dart
Krzysztof kuhy Rudnicki 4c6083b768 Add food bank, edit-entry, and app settings screens; history redesign
Work-in-progress feature set accumulated ahead of the log-meal compact
layout change: a food bank browser, a shared entry-edit screen, an
app-wide settings service, and a substantially reworked history screen
with filtering/sorting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UorgLvWJ4huH55tmXoUAZ
2026-07-04 05:18:32 +02:00

204 lines
6.3 KiB
Dart

/// 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}',
);
}
}
}