From f5d79a6a579804ae3668814a6ee5250089bf542d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 15 Jun 2026 22:21:34 +0200 Subject: [PATCH] One-tap GitHub connect via a baked-in OAuth App client id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously "Connect GitHub" (OAuth device flow) still required entering an OAuth App client id and owner/repo — friction that returned on every reinstall once shared_prefs were wiped. - Bake the app's own device-flow OAuth App client id in as SyncSettings.defaultClientId and default to it in load() (alongside the existing kuhyx/todo-sync repo default). A device-flow client id is a public identifier, not a secret, so it is safe to commit. - Settings now leads with a single "Connect GitHub" button; the manual client-id / token fields and Test connection move under an "Advanced" expander. Result: fresh install (or post-reinstall) is one tap → authorize the code in the browser → synced. No tokens, no setup. Note: an OAuth App authorizes with the classic `repo` scope (all repos), broader than the prior fine-grained PAT — the trade-off for one-tap device-flow convenience. 151 tests, 100% line coverage. Co-Authored-By: Claude Opus 4.8 --- lib/sync/sync_settings.dart | 16 +++-- lib/ui/settings_screen.dart | 116 ++++++++++++++++++--------------- test/settings_screen_test.dart | 6 ++ test/sync_settings_test.dart | 3 +- 4 files changed, 82 insertions(+), 59 deletions(-) diff --git a/lib/sync/sync_settings.dart b/lib/sync/sync_settings.dart index db08cbb..419a442 100644 --- a/lib/sync/sync_settings.dart +++ b/lib/sync/sync_settings.dart @@ -19,10 +19,15 @@ class SyncSettings { final String token; /// GitHub OAuth App client id used by the device-flow "Connect" button. - /// Not a secret (device flow needs no client secret), so it is safe to - /// store here and could later be shipped as a compile-time default. + /// Not a secret (device flow needs no client secret), so it is safe to ship + /// as a compile-time default and commit to source — see [defaultClientId]. final String clientId; + /// The app's own GitHub OAuth App (device-flow enabled) client id, baked in + /// so "Connect GitHub" works with zero setup — even after a reinstall. A + /// device-flow client id is a public identifier, not a secret. + static const defaultClientId = 'Ov23li9tF2R46PqzJgch'; + /// True when enough is set to attempt a sync. bool get isConfigured => owner.isNotEmpty && repo.isNotEmpty && token.isNotEmpty; @@ -35,15 +40,16 @@ class SyncSettings { static const _kToken = 'sync.token'; static const _kClientId = 'sync.clientId'; - /// Loads settings, defaulting the repo to `kuhyx/todo-sync` so the user - /// only has to authorize on first run. + /// Loads settings, defaulting the repo to `kuhyx/todo-sync` and the client id + /// to the baked-in [defaultClientId] so first run (and any reinstall) needs + /// nothing but a single "Connect GitHub" tap. static Future load() async { final prefs = await SharedPreferences.getInstance(); return SyncSettings( owner: prefs.getString(_kOwner) ?? 'kuhyx', repo: prefs.getString(_kRepo) ?? 'todo-sync', token: prefs.getString(_kToken) ?? '', - clientId: prefs.getString(_kClientId) ?? '', + clientId: prefs.getString(_kClientId) ?? defaultClientId, ); } diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index c7ed1ff..fa053d4 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -215,6 +215,23 @@ class _SettingsScreenState extends State { body: ListView( padding: const EdgeInsets.all(16), children: [ + Text( + 'Connect to GitHub', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + 'Authorize in your browser — no token to paste. Syncs to ' + 'kuhyx/todo-sync 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: _owner, decoration: const InputDecoration( @@ -230,65 +247,58 @@ class _SettingsScreenState extends State { border: OutlineInputBorder(), ), ), - const SizedBox(height: 24), - Text( - 'Connect with GitHub', - style: Theme.of(context).textTheme.titleMedium, - ), const SizedBox(height: 8), - TextField( - controller: _clientId, - decoration: const InputDecoration( - labelText: 'OAuth App client id', - helperText: 'From your GitHub OAuth App (device flow enabled)', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: _connectGitHub, - icon: const Icon(Icons.login), - label: const Text('Connect GitHub'), - ), - const SizedBox(height: 24), - const Divider(), - const SizedBox(height: 8), - Text( - 'Or paste a token (fallback)', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - TextField( - controller: _token, - obscureText: true, - decoration: const InputDecoration( - labelText: 'Access token (fine-grained PAT)', - helperText: 'Contents: read/write on the sync repo only', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - Row( + // Manual escape hatches — most users never open this. + ExpansionTile( + title: const Text('Advanced'), + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(bottom: 8), children: [ - OutlinedButton.icon( - onPressed: _testing ? null : _test, - icon: _testing - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.wifi_tethering), - label: const Text('Test connection'), + TextField( + controller: _clientId, + decoration: const InputDecoration( + labelText: 'OAuth App client id', + helperText: + 'Leave as the baked-in default unless self-hosting', + border: OutlineInputBorder(), + ), ), - const Spacer(), - FilledButton.icon( - onPressed: _save, - icon: const Icon(Icons.save), - label: const Text('Save'), + const SizedBox(height: 12), + TextField( + controller: _token, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Access token (fallback)', + helperText: 'Contents: read/write on the sync repo only', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: _testing ? null : _test, + icon: _testing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.wifi_tethering), + label: const Text('Test connection'), + ), ), ], ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + onPressed: _save, + icon: const Icon(Icons.save), + label: const Text('Save'), + ), + ), const SizedBox(height: 24), const Divider(), const SizedBox(height: 8), diff --git a/test/settings_screen_test.dart b/test/settings_screen_test.dart index 868c162..0d63712 100644 --- a/test/settings_screen_test.dart +++ b/test/settings_screen_test.dart @@ -130,6 +130,8 @@ void main() { final mock = MockClient((_) async => http.Response('{}', 200)); await pumpSettings(tester, httpClient: mock); + await tester.tap(find.text('Advanced')); // Test connection lives here now + await tester.pumpAndSettle(); await tester.tap(find.text('Test connection')); await tester.pump(); // start await tester.pump(); // resolve future + rebuild @@ -141,6 +143,8 @@ void main() { final mock = MockClient((_) async => http.Response('', 404)); await pumpSettings(tester, httpClient: mock); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); await tester.tap(find.text('Test connection')); await tester.pump(); await tester.pump(); @@ -152,6 +156,8 @@ void main() { final mock = MockClient((_) async => throw Exception('offline')); await pumpSettings(tester, httpClient: mock); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); await tester.tap(find.text('Test connection')); await tester.pump(); await tester.pump(); diff --git a/test/sync_settings_test.dart b/test/sync_settings_test.dart index 20c7566..043b289 100644 --- a/test/sync_settings_test.dart +++ b/test/sync_settings_test.dart @@ -11,7 +11,8 @@ void main() { expect(s.owner, 'kuhyx'); expect(s.repo, 'todo-sync'); expect(s.token, ''); - expect(s.clientId, ''); + // Client id defaults to the baked-in OAuth App id (one-tap connect). + expect(s.clientId, SyncSettings.defaultClientId); }, );