/// Locally-stored GitHub sync configuration, ported from `~/src/todo`'s /// `sync/sync_settings.dart`, including the OAuth device-flow fields: the /// "Connect GitHub" button is the primary path, with a pasted PAT kept as a /// manual fallback. library; import 'package:diet_guard_app/services/token_vault.dart'; import 'package:diet_guard_app/services/token_vault_factory.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// The GitHub token is kept out of `SharedPreferences`, which holds only the /// non-secret owner/repo: on Android it lives in the OS keystore, and in the /// desktop web build it lives in the wrapper process rather than the browser /// at all (see [TokenVault]). Older builds stored the token in plaintext /// prefs; [load]/[save] migrate it transparently and never drop the plaintext /// copy until a vault write is confirmed (so we degrade to -- never below -- /// the old behaviour when no secure store is available). class SyncSettings { /// Creates a [SyncSettings] from its owner/repo/token. const SyncSettings({ required this.owner, required this.repo, required this.token, this.clientId = '', }); /// The repo owner/org (e.g. `"kuhyx"`). final String owner; /// The repo name (e.g. `"syncs"`). final String repo; /// A GitHub PAT with contents read/write on [owner]/[repo]. 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 /// 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. Registered 2026-06-23 at /// github.com/settings/developers, device flow enabled — distinct from /// the sibling notes app's OAuth App, which belongs to a different /// product. static const defaultClientId = 'Ov23li8wIQBai3qtbsqa'; /// True when enough is set to attempt a sync. bool get isConfigured => owner.isNotEmpty && repo.isNotEmpty && token.isNotEmpty; /// True when device-flow "Connect GitHub" can be offered. bool get canUseDeviceFlow => clientId.isNotEmpty; static const _kOwner = 'sync.owner'; static const _kRepo = 'sync.repo'; static const _kClientId = 'sync.clientId'; // Legacy plaintext location for the token; read-only now and removed once // the token has been migrated into secure storage. static const _kToken = 'sync.token'; static final TokenVault _vault = openTokenVault(); /// True when this platform can show the stored token back to the user. static bool get exposesTokenValue => _vault.exposesTokenValue; /// Loads settings, defaulting the owner/repo to `kuhyx/syncs` /// (matching the PC's `SYNC_REPO_OWNER`/`SYNC_REPO_NAME` constants) and the /// client id to the baked-in [defaultClientId], so a fresh install needs /// only "Connect GitHub" (once an OAuth App is registered) or a pasted PAT. /// /// A device that already persisted the old standalone `diet-guard-sync` /// repo name (before it was folded into the shared `syncs` monorepo) is /// migrated to `syncs` here and the corrected value is written straight /// back to prefs, so this only ever runs once per device. static Future load() async { final prefs = await SharedPreferences.getInstance(); var repo = prefs.getString(_kRepo) ?? 'syncs'; if (repo == 'diet-guard-sync') { repo = 'syncs'; await prefs.setString(_kRepo, repo); } return SyncSettings( owner: prefs.getString(_kOwner) ?? 'kuhyx', repo: repo, token: await _loadToken(prefs), clientId: prefs.getString(_kClientId) ?? defaultClientId, ); } /// Reads the token, preferring the vault and falling back to the legacy /// plaintext value. A legacy value is migrated into the vault on read, but /// only dropped from prefs once that write succeeds. static Future _loadToken(SharedPreferences prefs) async { final stored = await _vault.read(); if (stored.isNotEmpty) return stored; final legacy = prefs.getString(_kToken) ?? ''; if (legacy.isNotEmpty && await _vault.write(legacy)) { await prefs.remove(_kToken); } return legacy; } /// Persists [owner]/[repo] to prefs and [token] to the vault (or plaintext /// prefs as a fallback -- see [_loadToken]). Future save() async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kOwner, owner); await prefs.setString(_kRepo, repo); await prefs.setString(_kClientId, clientId); // Confirm-before-delete: only remove the plaintext copy once the vault // write succeeds; otherwise keep persisting it to prefs as before. if (await _vault.write(token)) { await prefs.remove(_kToken); } else { await prefs.setString(_kToken, token); } } /// Returns a copy of this with only the given fields replaced. SyncSettings copyWith({ String? owner, String? repo, String? token, String? clientId, }) { return SyncSettings( owner: owner ?? this.owner, repo: repo ?? this.repo, token: token ?? this.token, clientId: clientId ?? this.clientId, ); } }