mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
Make Firebase the visible sync path, and GitHub the mirror it now is
Four things about the sync settings read wrong on the phone, all of them left over from when GitHub was the only backend: - "Connect GitHub" sat on the primary path as a peer of "Connect Firebase", implying a choice. It is not one: GitHub is the cutover mirror. Its whole configuration -- device-flow OAuth, owner, repo, client id, PAT fallback -- now lives under "Advanced (GitHub mirror)". - "Test connection" builds a GitHubClient and calls canAccessRepo(). It has never said anything about Firebase, so it is now "Test GitHub connection", and its result messages name GitHub too. - Once connected, the account still rendered as an editable email box beside an empty password box, which reads as "you still have to enter this" -- a connected device looked unconfigured. It now shows the account as text with a Disconnect button, and the form appears only when disconnected. Also pins crdt-sync v0.5.1 (from v0.3.0), the shared-config release the Firebase cutover needs; verified importable under /usr/bin/python3, which is what diet-guard-gate.service actually runs. Adds coverage for both Firebase states, which had none: the disconnected form and the connected read-only display. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
This commit is contained in:
parent
da0b54d886
commit
4d803203ab
@ -1,9 +1,10 @@
|
||||
/// GitHub sync configuration. Primary path: "Connect GitHub" runs the OAuth
|
||||
/// **device flow** (authorize in a browser, no token pasting). A manually
|
||||
/// pasted PAT remains as a fallback under "Advanced". Auto-sync (app launch
|
||||
/// + lifecycle pause/resume) lives in [LogMealScreen] and is silent on
|
||||
/// failure -- this screen is where errors get surfaced, as inline status
|
||||
/// text.
|
||||
/// Sync configuration. Primary path: "Connect Firebase" — sign in with the
|
||||
/// shared sync account, whose password is kept in the OS keystore. GitHub is
|
||||
/// only the cutover mirror, so its whole setup (device-flow OAuth, owner/repo,
|
||||
/// client id, PAT fallback) sits under "Advanced (GitHub mirror)" rather than
|
||||
/// competing with Firebase as a visible choice. Auto-sync (app launch +
|
||||
/// lifecycle pause/resume) lives in [LogMealScreen] and is silent on failure
|
||||
/// -- this screen is where errors get surfaced, as inline status text.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
@ -232,9 +233,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final client = createGitHubClient(settings, httpClient: widget.httpClient);
|
||||
try {
|
||||
final ok = await client.canAccessRepo();
|
||||
_showMessage(ok ? 'Connection OK.' : 'Connection failed.');
|
||||
_showMessage(
|
||||
ok ? 'GitHub connection OK.' : 'GitHub connection failed.',
|
||||
);
|
||||
} on Exception catch (e) {
|
||||
_showMessage('Connection failed: $e');
|
||||
_showMessage('GitHub connection failed: $e');
|
||||
} finally {
|
||||
client.close();
|
||||
if (mounted) setState(() => _busy = false);
|
||||
@ -395,70 +398,94 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _firebaseEmailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Sync account email',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _firebasePasswordController,
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Sync account password',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: _busy ? null : _saveFirebaseAccount,
|
||||
icon: const Icon(Icons.cloud_done),
|
||||
label: const Text('Connect Firebase'),
|
||||
),
|
||||
if (_firebaseConfigured) ...[
|
||||
// Once connected, the account is shown as read-only text rather
|
||||
// than as prefilled inputs: an editable email box next to an
|
||||
// empty password box reads as "you still have to enter this",
|
||||
// which made a connected device look unconfigured.
|
||||
if (_firebaseConfigured)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.cloud_done, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_firebaseEmailController.text,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _disconnectFirebase,
|
||||
child: const Text('Disconnect'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
TextField(
|
||||
controller: _firebaseEmailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Sync account email',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _firebasePasswordController,
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Sync account password',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _busy ? null : _saveFirebaseAccount,
|
||||
icon: const Icon(Icons.cloud_done),
|
||||
label: const Text('Connect Firebase'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Authorize in your browser — no token to paste. Syncs to '
|
||||
'kuhyx/syncs by default.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _connectGitHub,
|
||||
icon: const Icon(Icons.login),
|
||||
label: const Text('Connect GitHub'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
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),
|
||||
// GitHub is a mirror during the cutover, not something to pick:
|
||||
// its whole configuration lives under "Advanced" so the primary
|
||||
// path stays a single choice. See CLAUDE.md "Cross-device sync".
|
||||
ExpansionTile(
|
||||
title: const Text('Advanced'),
|
||||
title: const Text('Advanced (GitHub mirror)'),
|
||||
tilePadding: EdgeInsets.zero,
|
||||
childrenPadding: const EdgeInsets.only(bottom: 8),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Syncs still mirror to GitHub until every device has '
|
||||
'moved to Firebase. Authorize in your browser — no '
|
||||
'token to paste.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _connectGitHub,
|
||||
icon: const Icon(Icons.login),
|
||||
label: const Text('Connect GitHub'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
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: _clientIdController,
|
||||
decoration: const InputDecoration(
|
||||
@ -498,7 +525,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: _busy ? null : _testConnection,
|
||||
child: const Text('Test connection'),
|
||||
// Named for what it actually does: _testConnection builds a
|
||||
// GitHub client and calls canAccessRepo(). It says nothing
|
||||
// about Firebase, and the bare label implied otherwise.
|
||||
child: const Text('Test GitHub connection'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _busy ? null : _syncNow,
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/services/document_store_io.dart';
|
||||
import 'package:diet_guard_app/screens/settings_screen.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/budget_history_service.dart';
|
||||
import 'package:diet_guard_app/services/firebase_backend.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';
|
||||
@ -77,6 +79,16 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Expands the "Advanced (GitHub mirror)" section.
|
||||
///
|
||||
/// GitHub is the cutover mirror rather than a choice the user makes, so its
|
||||
/// whole configuration (owner, repo, client id, PAT, "Connect GitHub") is
|
||||
/// collapsed by default. Every GitHub-facing test has to open it first.
|
||||
Future<void> openAdvanced(WidgetTester tester) async {
|
||||
await tester.tap(find.text('Advanced (GitHub mirror)'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Drains the device flow's real `Future.delayed` poll (GitHubDeviceAuth
|
||||
/// injects no test delay, so under `runAsync` it is a genuine Timer, not
|
||||
/// the fake-clock one `tester.pump(duration)` advances) by interleaving
|
||||
@ -99,6 +111,9 @@ void main() {
|
||||
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
|
||||
await settle(tester);
|
||||
|
||||
// owner/repo moved under the collapsed GitHub-mirror section.
|
||||
await openAdvanced(tester);
|
||||
|
||||
expect(find.widgetWithText(TextField, 'kuhyx'), findsOneWidget);
|
||||
expect(find.widgetWithText(TextField, 'syncs'), findsOneWidget);
|
||||
});
|
||||
@ -109,8 +124,7 @@ void main() {
|
||||
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
await openAdvanced(tester);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Personal access token (fallback)'),
|
||||
'my-pat',
|
||||
@ -132,10 +146,12 @@ void main() {
|
||||
);
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, 'Test connection'));
|
||||
await tester.tap(
|
||||
find.widgetWithText(OutlinedButton, 'Test GitHub connection'),
|
||||
);
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Connection OK.'), findsOneWidget);
|
||||
expect(find.text('GitHub connection OK.'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
@ -147,10 +163,12 @@ void main() {
|
||||
);
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, 'Test connection'));
|
||||
await tester.tap(
|
||||
find.widgetWithText(OutlinedButton, 'Test GitHub connection'),
|
||||
);
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Connection failed.'), findsOneWidget);
|
||||
expect(find.text('GitHub connection failed.'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
@ -189,10 +207,12 @@ void main() {
|
||||
);
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, 'Test connection'));
|
||||
await tester.tap(
|
||||
find.widgetWithText(OutlinedButton, 'Test GitHub connection'),
|
||||
);
|
||||
await settle(tester);
|
||||
|
||||
expect(find.textContaining('Connection failed:'), findsOneWidget);
|
||||
expect(find.textContaining('GitHub connection failed:'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
@ -211,18 +231,67 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('shows the Connect GitHub button', (tester) async {
|
||||
testWidgets('offers the account form when Firebase is not connected', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
|
||||
await settle(tester);
|
||||
|
||||
expect(
|
||||
find.widgetWithText(TextField, 'Sync account email'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.widgetWithText(TextField, 'Sync account password'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Disconnect'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('shows the connected account as text, not as an empty form', (
|
||||
tester,
|
||||
) async {
|
||||
// An editable email box beside a blank password box reads as "you still
|
||||
// have to enter this", so a connected device looked unconfigured.
|
||||
await saveAccount(
|
||||
const FirebaseAccount(email: 'sync@example.com', password: 'pw'),
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('sync@example.com'), findsOneWidget);
|
||||
expect(find.text('Disconnect'), findsOneWidget);
|
||||
expect(
|
||||
find.widgetWithText(TextField, 'Sync account password'),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.text('Connect Firebase'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('hides the Connect GitHub button until Advanced is expanded', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
|
||||
await settle(tester);
|
||||
|
||||
// GitHub is the cutover mirror, not a choice competing with Firebase,
|
||||
// so nothing GitHub-facing is visible on the primary path.
|
||||
expect(find.text('Connect GitHub'), findsNothing);
|
||||
|
||||
await openAdvanced(tester);
|
||||
|
||||
expect(find.text('Connect GitHub'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
/// Expands "Advanced" and types [clientId] into the client-id field.
|
||||
Future<void> enterClientId(WidgetTester tester, String clientId) async {
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.tap(find.text('Advanced (GitHub mirror)'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'OAuth App client id'),
|
||||
|
||||
@ -5,7 +5,7 @@ description = "Tkinter/systemd log-to-unlock meal gate with calorie/macro budget
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock",
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.3.0#subdirectory=crdt-sync",
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.5.1#subdirectory=crdt-sync",
|
||||
"requests>=2.31.0",
|
||||
]
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
bandit>=1.7.0
|
||||
codespell>=2.2.0
|
||||
coverage>=7.4.0
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.3.0#subdirectory=crdt-sync
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.5.1#subdirectory=crdt-sync
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
|
||||
mcp>=2,<3
|
||||
mypy>=1.8.0
|
||||
|
||||
Loading…
Reference in New Issue
Block a user