mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 13:43:38 +02:00
One-tap GitHub connect via a baked-in OAuth App client id
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 <noreply@anthropic.com>
This commit is contained in:
parent
6db9ee11d0
commit
f5d79a6a57
@ -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<SyncSettings> 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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -215,6 +215,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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<SettingsScreen> {
|
||||
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),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user