mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 13:23:15 +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;
|
final String token;
|
||||||
|
|
||||||
/// GitHub OAuth App client id used by the device-flow "Connect" button.
|
/// 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
|
/// Not a secret (device flow needs no client secret), so it is safe to ship
|
||||||
/// store here and could later be shipped as a compile-time default.
|
/// as a compile-time default and commit to source — see [defaultClientId].
|
||||||
final String clientId;
|
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.
|
/// True when enough is set to attempt a sync.
|
||||||
bool get isConfigured =>
|
bool get isConfigured =>
|
||||||
owner.isNotEmpty && repo.isNotEmpty && token.isNotEmpty;
|
owner.isNotEmpty && repo.isNotEmpty && token.isNotEmpty;
|
||||||
@ -35,15 +40,16 @@ class SyncSettings {
|
|||||||
static const _kToken = 'sync.token';
|
static const _kToken = 'sync.token';
|
||||||
static const _kClientId = 'sync.clientId';
|
static const _kClientId = 'sync.clientId';
|
||||||
|
|
||||||
/// Loads settings, defaulting the repo to `kuhyx/todo-sync` so the user
|
/// Loads settings, defaulting the repo to `kuhyx/todo-sync` and the client id
|
||||||
/// only has to authorize on first run.
|
/// to the baked-in [defaultClientId] so first run (and any reinstall) needs
|
||||||
|
/// nothing but a single "Connect GitHub" tap.
|
||||||
static Future<SyncSettings> load() async {
|
static Future<SyncSettings> load() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return SyncSettings(
|
return SyncSettings(
|
||||||
owner: prefs.getString(_kOwner) ?? 'kuhyx',
|
owner: prefs.getString(_kOwner) ?? 'kuhyx',
|
||||||
repo: prefs.getString(_kRepo) ?? 'todo-sync',
|
repo: prefs.getString(_kRepo) ?? 'todo-sync',
|
||||||
token: prefs.getString(_kToken) ?? '',
|
token: prefs.getString(_kToken) ?? '',
|
||||||
clientId: prefs.getString(_kClientId) ?? '',
|
clientId: prefs.getString(_kClientId) ?? defaultClientId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -215,6 +215,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
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(
|
TextField(
|
||||||
controller: _owner,
|
controller: _owner,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
@ -230,65 +247,58 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text(
|
|
||||||
'Connect with GitHub',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
// Manual escape hatches — most users never open this.
|
||||||
controller: _clientId,
|
ExpansionTile(
|
||||||
decoration: const InputDecoration(
|
title: const Text('Advanced'),
|
||||||
labelText: 'OAuth App client id',
|
tilePadding: EdgeInsets.zero,
|
||||||
helperText: 'From your GitHub OAuth App (device flow enabled)',
|
childrenPadding: const EdgeInsets.only(bottom: 8),
|
||||||
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(
|
|
||||||
children: [
|
children: [
|
||||||
OutlinedButton.icon(
|
TextField(
|
||||||
onPressed: _testing ? null : _test,
|
controller: _clientId,
|
||||||
icon: _testing
|
decoration: const InputDecoration(
|
||||||
? const SizedBox(
|
labelText: 'OAuth App client id',
|
||||||
width: 16,
|
helperText:
|
||||||
height: 16,
|
'Leave as the baked-in default unless self-hosting',
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
border: OutlineInputBorder(),
|
||||||
)
|
),
|
||||||
: const Icon(Icons.wifi_tethering),
|
|
||||||
label: const Text('Test connection'),
|
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const SizedBox(height: 12),
|
||||||
FilledButton.icon(
|
TextField(
|
||||||
onPressed: _save,
|
controller: _token,
|
||||||
icon: const Icon(Icons.save),
|
obscureText: true,
|
||||||
label: const Text('Save'),
|
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 SizedBox(height: 24),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@ -130,6 +130,8 @@ void main() {
|
|||||||
final mock = MockClient((_) async => http.Response('{}', 200));
|
final mock = MockClient((_) async => http.Response('{}', 200));
|
||||||
await pumpSettings(tester, httpClient: mock);
|
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.tap(find.text('Test connection'));
|
||||||
await tester.pump(); // start
|
await tester.pump(); // start
|
||||||
await tester.pump(); // resolve future + rebuild
|
await tester.pump(); // resolve future + rebuild
|
||||||
@ -141,6 +143,8 @@ void main() {
|
|||||||
final mock = MockClient((_) async => http.Response('', 404));
|
final mock = MockClient((_) async => http.Response('', 404));
|
||||||
await pumpSettings(tester, httpClient: mock);
|
await pumpSettings(tester, httpClient: mock);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Advanced'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Test connection'));
|
await tester.tap(find.text('Test connection'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@ -152,6 +156,8 @@ void main() {
|
|||||||
final mock = MockClient((_) async => throw Exception('offline'));
|
final mock = MockClient((_) async => throw Exception('offline'));
|
||||||
await pumpSettings(tester, httpClient: mock);
|
await pumpSettings(tester, httpClient: mock);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Advanced'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Test connection'));
|
await tester.tap(find.text('Test connection'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|||||||
@ -11,7 +11,8 @@ void main() {
|
|||||||
expect(s.owner, 'kuhyx');
|
expect(s.owner, 'kuhyx');
|
||||||
expect(s.repo, 'todo-sync');
|
expect(s.repo, 'todo-sync');
|
||||||
expect(s.token, '');
|
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