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