/// Settings screen: per-exercise streak thresholds and manual weight overrides. /// Changes are saved immediately; a "Reset to defaults" button reverts all. library; import 'dart:async'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/workout_plan.dart'; import 'package:workout_app/services/firebase_backend.dart'; import 'package:workout_app/services/github_device_auth.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/sync_settings.dart'; import 'package:workout_app/ui/theme.dart'; /// How to style a [_SyncStatusBadge]. enum _SyncStatusKind { success, pending, error } /// Screen for editing per-exercise thresholds and manual weight overrides. class SettingsScreen extends StatefulWidget { /// Creates a [SettingsScreen]. const SettingsScreen({ super.key, this.httpClient, this.firebaseFactory, this.accountLoader, this.accountSaver, this.accountClearer, }); /// Injectable HTTP client; tests pass a `MockClient` so the device-flow /// requests never hit the real network. final http.Client? httpClient; /// Builds the Firebase client. Injected so tests need no platform channel. final Future Function()? firebaseFactory; /// Keystore accessors for the Firebase account. Injected as a group so the /// connect/disconnect flows are testable without a platform channel -- /// `flutter test` has no binding for one. final Future Function()? accountLoader; /// Persists the account. See [accountLoader]. final Future Function(FirebaseAccount)? accountSaver; /// Forgets the account and any cached session. See [accountLoader]. final Future Function()? accountClearer; @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State { bool _loading = true; final Map _successThresholds = {}; final Map _failThresholds = {}; final Map _weights = {}; final Map _reps = {}; // Debounce weight saves to avoid resetting streaks on every tap. final Map _weightTimers = {}; // Same debounce for reps, for the same reason. final Map _repsTimers = {}; final _tokenController = TextEditingController(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); bool _firebaseConnected = false; bool _firebaseBusy = false; // Persistent (not a transient SnackBar) so the result of Connect GitHub / // Save is still visible if the user looks back at the screen later -- // mirrors diet-guard/todo's `_status` field in their settings screens. String? _syncStatus; _SyncStatusKind _syncStatusKind = _SyncStatusKind.pending; void _setSyncStatus(String message, _SyncStatusKind kind) { setState(() { _syncStatus = message; _syncStatusKind = kind; }); } @override void initState() { super.initState(); unawaited(_load()); unawaited(_loadFirebaseAccount()); } /// Reflects a previously-stored account, so a returning user sees the real /// state instead of an empty form that looks unconfigured. Future _loadFirebaseAccount() async { final account = await (widget.accountLoader ?? loadAccount)(); if (!mounted) return; if (account != null) _emailController.text = account.email; setState(() => _firebaseConnected = account != null); } /// Stores the typed account and signs in immediately, so a typo surfaces /// here rather than as a silent background failure on the next push. /// /// Without this the app could never reach Firebase at all: `openFirebase()` /// would read an account nothing had ever written. Future _connectFirebase() async { final email = _emailController.text.trim(); final password = _passwordController.text; if (email.isEmpty || password.isEmpty) { _setSyncStatus( 'Enter the sync account email and password.', _SyncStatusKind.error, ); return; } setState(() => _firebaseBusy = true); await (widget.accountSaver ?? saveAccount)( FirebaseAccount(email: email, password: password), ); final client = await (widget.firebaseFactory ?? openFirebase)(); if (!mounted) return; if (client == null) { await (widget.accountClearer ?? clearAccount)(); if (!mounted) return; setState(() { _firebaseBusy = false; _firebaseConnected = false; }); _setSyncStatus( 'Firebase rejected that account.', _SyncStatusKind.error, ); return; } _passwordController.clear(); setState(() { _firebaseBusy = false; _firebaseConnected = true; }); _setSyncStatus('Connected to Firebase.', _SyncStatusKind.success); } Future _disconnectFirebase() async { await (widget.accountClearer ?? clearAccount)(); if (!mounted) return; _emailController.clear(); _passwordController.clear(); setState(() => _firebaseConnected = false); _setSyncStatus('Firebase disconnected.', _SyncStatusKind.pending); } @override void dispose() { for (final t in _weightTimers.values) { t.cancel(); } for (final t in _repsTimers.values) { t.cancel(); } _tokenController.dispose(); _emailController.dispose(); _passwordController.dispose(); super.dispose(); } Future _load() async { final states = await StorageService.instance.getAllExerciseStates(); final syncSettings = await SyncSettings.load(); if (mounted) { setState(() { for (final s in states) { _successThresholds[s.name] = s.successThreshold; _failThresholds[s.name] = s.failThreshold; _weights[s.name] = s.weight; _reps[s.name] = s.reps; } _tokenController.text = syncSettings.token; _loading = false; }); // Never claim "Connected" just because a token STRING exists — check it // against the API. A revoked/expired token otherwise shows a reassuring // green badge while every sync 401s and the history silently stays empty. if (syncSettings.isConfigured) { _setSyncStatus('Verifying…', _SyncStatusKind.pending); await _verifyConnection(syncSettings.token); } } } Future _saveToken() async { final saved = await SyncSettings( token: _tokenController.text.trim(), ).save(); if (!mounted) return; _setSyncStatus( saved ? 'Sync token saved.' : 'Could not save token on this device.', saved ? _SyncStatusKind.success : _SyncStatusKind.error, ); } /// Runs the OAuth device flow and, on success, saves the resulting token /// and verifies it actually works against the sync repo -- a saved token /// that can't reach `$syncRepoOwner/$syncRepoName` (wrong scope, revoked, /// etc.) must be surfaced immediately, not discovered on the next workout. Future _connectGitHub() async { final auth = GitHubDeviceAuth( clientId: SyncSettings.defaultClientId, httpClient: widget.httpClient, ); try { final device = await auth.requestDeviceCode(); if (!mounted) return; final token = await showDialog( context: context, barrierDismissible: false, builder: (_) => _DeviceCodeDialog(device: device, auth: auth), ); if (token != null && token.isNotEmpty) { setState(() => _tokenController.text = token); _setSyncStatus('Connected — verifying…', _SyncStatusKind.pending); final saved = await SyncSettings(token: token).save(); if (!saved) { if (!mounted) return; _setSyncStatus( 'Connected, but could not save the token on this device.', _SyncStatusKind.error, ); return; } await _verifyConnection(token); } } on Exception catch (e) { if (!mounted) return; _setSyncStatus('Could not start device flow: $e', _SyncStatusKind.error); } finally { auth.close(); } } /// Confirms [token] can actually read `$syncRepoOwner/$syncRepoName`. /// Returns null when [token] works, else the error GitHub gave. Future _tryVerify(String token) async { final client = GitHubClient( owner: syncRepoOwner, repo: syncRepoName, token: token, httpClient: widget.httpClient, ); try { await client.getFileText('devices/phone/log.json'); return null; } on GitHubSyncError catch (e) { return e; } finally { client.close(); } } Future _verifyConnection(String token) async { final error = await _tryVerify(token); if (error == null) { if (!mounted) return; _setSyncStatus( 'Connected and verified via GitHub.', _SyncStatusKind.success, ); return; } // The keystore's token may be a stale one shadowing a good backup, and // load() only consults the backup when the keystore is EMPTY. Try the // backup once before making the user re-authorize for nothing. final recovered = await SyncSettings.recoverFromBackup(token); if (recovered != null && await _tryVerify(recovered) == null) { if (!mounted) return; _tokenController.text = recovered; _setSyncStatus( 'Connected and verified via GitHub (recovered the saved token from ' 'backup — the stored one had been rejected).', _SyncStatusKind.success, ); return; } if (!mounted) return; // Say plainly that sync is broken. "Connected, but…" reads as success // and is how a dead token hid behind a green badge. _setSyncStatus( error.toString().contains('401') ? 'NOT connected: GitHub rejected this token (401) and no working ' 'backup was found. Tap Connect GitHub to re-authorize — ' 'until then nothing syncs.' : 'NOT syncing: could not reach GitHub ($error)', _SyncStatusKind.error, ); } void _onWeightChanged(String name, double value) { setState(() => _weights[name] = value); _weightTimers[name]?.cancel(); _weightTimers[name] = Timer(const Duration(milliseconds: 600), () { unawaited(StorageService.instance.setExerciseWeight(name, value)); }); } void _onRepsChanged(String name, int value) { setState(() => _reps[name] = value); _repsTimers[name]?.cancel(); _repsTimers[name] = Timer(const Duration(milliseconds: 600), () { unawaited(StorageService.instance.setExerciseReps(name, value)); }); } Future _onThresholdChanged(String name, int success, int fail) async { setState(() { _successThresholds[name] = success; _failThresholds[name] = fail; }); await StorageService.instance.setExerciseThresholds( name, successThreshold: success, failThreshold: fail, ); } Future _resetToDefaults() async { final colorScheme = Theme.of(context).colorScheme; final ok = await showDialog( context: context, builder: (_) => AlertDialog( backgroundColor: colorScheme.surfaceContainerHigh, title: Text( 'Reset to defaults?', style: TextStyle(color: colorScheme.onSurface), ), content: Text( 'All weights and thresholds will be reset. ' 'Streak counters will be cleared.', style: TextStyle(color: colorScheme.onSurfaceVariant), ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: Text( 'Cancel', style: TextStyle(color: colorScheme.onSurfaceVariant), ), ), TextButton( onPressed: () => Navigator.pop(context, true), child: Text('Reset', style: TextStyle(color: colorScheme.error)), ), ], ), ); if (ok == true) { for (final name in _orderedNames) { await StorageService.instance.resetExerciseToDefaults(name); } await _load(); } } List get _orderedNames { final seen = {}; return [ ...workoutA, ...workoutB, ].map((e) => e.name).where(seen.add).toList(); } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar( backgroundColor: colorScheme.surfaceContainerHigh, title: Text( 'Settings', style: TextStyle(color: colorScheme.onSurface), ), iconTheme: IconThemeData(color: colorScheme.onSurface), actions: [ TextButton( onPressed: _loading ? null : _resetToDefaults, child: Text( 'Reset defaults', style: TextStyle(color: colorScheme.error), ), ), ], ), body: _loading ? const Center(child: CircularProgressIndicator()) : ListView( padding: const EdgeInsets.all(16), children: [ const _SectionHeader('WEIGHTS'), const SizedBox(height: 4), Text( 'Override current working weight. ' 'Resets streak counters. Rounded to 2.5 kg.', style: TextStyle( color: colorScheme.onSurfaceVariant, fontSize: AppTextSize.caption, ), ), const SizedBox(height: 12), ..._orderedNames.map((name) { final w = _weights[name]; if (w == null) return const SizedBox.shrink(); return _WeightRow( name: name, weight: w, onChanged: (v) => _onWeightChanged(name, v), ); }), const SizedBox(height: 20), const _SectionHeader('TARGET REPS'), const SizedBox(height: 4), Text( 'Override target reps per set. Resets streak counters. ' 'Progression only ever raises reps, so this is the only way ' 'to lower one.', style: TextStyle( color: colorScheme.onSurfaceVariant, fontSize: AppTextSize.caption, ), ), const SizedBox(height: 12), ..._orderedNames.map((name) { final r = _reps[name]; if (r == null) return const SizedBox.shrink(); return _RepsRow( name: name, reps: r, onChanged: (v) => _onRepsChanged(name, v), ); }), const SizedBox(height: 20), const _SectionHeader('PROGRESSION THRESHOLDS'), const SizedBox(height: 4), Text( 'Consecutive successes (↑) or failures (↓) ' 'before weight changes.', style: TextStyle( color: colorScheme.onSurfaceVariant, fontSize: AppTextSize.caption, ), ), const SizedBox(height: 12), ..._orderedNames.map((name) { final sThresh = _successThresholds[name] ?? 3; final fThresh = _failThresholds[name] ?? 2; return _ExerciseThresholdCard( name: name, successThreshold: sThresh, failThreshold: fThresh, onSuccessChanged: (v) => _onThresholdChanged(name, v, _failThresholds[name]!), onFailChanged: (v) => _onThresholdChanged(name, _successThresholds[name]!, v), ); }), const SizedBox(height: 20), const _SectionHeader('SYNC'), const SizedBox(height: 4), Text( _firebaseConnected ? 'Connected. Workouts go to Firebase first, and still ' 'mirror to GitHub until every device has moved.' : 'Not connected -- syncing over GitHub only. Enter the ' 'shared sync account to move this device over. ' 'The password is kept in the device keystore, ' 'never in the app or the repo.', style: TextStyle( color: colorScheme.onSurfaceVariant, fontSize: AppTextSize.caption, ), ), const SizedBox(height: 12), if (_syncStatus != null) ...[ _SyncStatusBadge(text: _syncStatus!, kind: _syncStatusKind), const SizedBox(height: 12), ], // Once connected the account is read-only text: an editable // email box beside an empty password box reads as "you still // have to enter this", making a connected device look // unconfigured. if (_firebaseConnected) Row( children: [ const Icon(Icons.cloud_done, size: 20), const SizedBox(width: 8), Expanded(child: Text(_emailController.text)), TextButton( onPressed: _firebaseBusy ? null : _disconnectFirebase, child: const Text('Disconnect'), ), ], ) else ...[ TextField( controller: _emailController, keyboardType: TextInputType.emailAddress, autocorrect: false, decoration: const InputDecoration( labelText: 'Sync account email', ), ), const SizedBox(height: 8), TextField( controller: _passwordController, obscureText: true, autocorrect: false, decoration: const InputDecoration( labelText: 'Sync account password', ), ), const SizedBox(height: 12), ElevatedButton.icon( onPressed: _firebaseBusy ? null : _connectFirebase, icon: const Icon(Icons.cloud_done), label: const Text('Connect Firebase'), ), ], const SizedBox(height: 8), // GitHub is the cutover mirror, not a choice competing with // Firebase, so its connect button moves in here too. ExpansionTile( title: Text( 'Advanced (GitHub mirror)', style: TextStyle( color: colorScheme.onSurfaceVariant, fontSize: AppTextSize.label, ), ), collapsedIconColor: colorScheme.onSurfaceVariant, iconColor: colorScheme.onSurfaceVariant, tilePadding: EdgeInsets.zero, children: [ Align( alignment: Alignment.centerLeft, child: Text( 'Authorize in your browser -- no token to paste. ' 'Syncs to $syncRepoOwner/$syncRepoName.', style: TextStyle( color: colorScheme.onSurfaceVariant, fontSize: AppTextSize.caption, ), ), ), const SizedBox(height: 12), Align( alignment: Alignment.centerLeft, child: ElevatedButton.icon( onPressed: _connectGitHub, icon: const Icon(Icons.login), label: const Text('Connect GitHub'), ), ), const SizedBox(height: 12), // The PAT fallback lives inside the mirror section too: // two sibling "Advanced" disclosures made it look like // there were two independent things to configure. _SyncTokenField( controller: _tokenController, onSave: _saveToken, ), ], ), ], ), ); } } class _SectionHeader extends StatelessWidget { const _SectionHeader(this.text); final String text; @override Widget build(BuildContext context) { return Text( text, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: AppTextSize.caption, letterSpacing: 1.4, ), ); } } class _RepsRow extends StatelessWidget { const _RepsRow({ required this.name, required this.reps, required this.onChanged, }); final String name; final int reps; final ValueChanged onChanged; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.only(bottom: 10), child: Row( children: [ Expanded( child: Text( name, style: TextStyle( color: colorScheme.onSurfaceVariant, fontSize: AppTextSize.label, ), ), ), _StepperButton( icon: Icons.remove, onTap: () => onChanged((reps - 1).clamp(1, 999)), ), SizedBox( width: 72, child: Text( '$reps reps', textAlign: TextAlign.center, style: TextStyle( color: colorScheme.onSurface, fontSize: AppTextSize.label, fontWeight: FontWeight.bold, ), ), ), _StepperButton( icon: Icons.add, onTap: () => onChanged((reps + 1).clamp(1, 999)), ), ], ), ); } } class _WeightRow extends StatelessWidget { const _WeightRow({ required this.name, required this.weight, required this.onChanged, }); final String name; final double weight; final ValueChanged onChanged; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.only(bottom: 10), child: Row( children: [ Expanded( child: Text( name, style: TextStyle( color: colorScheme.onSurfaceVariant, fontSize: AppTextSize.label, ), ), ), _StepperButton( icon: Icons.remove, onTap: () => onChanged( (weight - kWeightIncrement).clamp(0.0, 999.0), ), ), // Fixed-width container supports up to "999.9kg" (7 chars). SizedBox( width: 72, child: Text( '${weight}kg', textAlign: TextAlign.center, style: TextStyle( color: colorScheme.onSurface, fontSize: AppTextSize.label, fontWeight: FontWeight.bold, ), ), ), _StepperButton( icon: Icons.add, onTap: () => onChanged(weight + kWeightIncrement), ), ], ), ); } } class _StepperButton extends StatelessWidget { const _StepperButton({required this.icon, required this.onTap}); final IconData icon; final VoidCallback onTap; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return GestureDetector( onTap: onTap, child: Container( width: 36, height: 36, decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(6), ), alignment: Alignment.center, child: Icon(icon, color: colorScheme.onSurface, size: 18), ), ); } } /// A visible, colored status pill for the GitHub sync connection state -- /// placed directly under the section description (not buried below the /// collapsed Advanced field) so "am I connected?" has an immediate answer. class _SyncStatusBadge extends StatelessWidget { const _SyncStatusBadge({required this.text, required this.kind}); final String text; final _SyncStatusKind kind; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final status = Theme.of(context).extension()!; final color = switch (kind) { _SyncStatusKind.success => status.success, _SyncStatusKind.error => colorScheme.error, _SyncStatusKind.pending => colorScheme.onSurfaceVariant, }; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), border: Border.all(color: color.withValues(alpha: 0.4)), ), child: Row( children: [ if (kind == _SyncStatusKind.pending) const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) else Icon( kind == _SyncStatusKind.success ? Icons.check_circle : Icons.error, color: color, size: 16, ), const SizedBox(width: 10), Expanded( child: Text( text, style: TextStyle( color: color, fontSize: AppTextSize.label, fontWeight: FontWeight.w600, ), ), ), ], ), ); } } class _SyncTokenField extends StatelessWidget { const _SyncTokenField({required this.controller, required this.onSave}); final TextEditingController controller; final VoidCallback onSave; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextField( controller: controller, obscureText: true, style: TextStyle(color: colorScheme.onSurface), // filled/fillColor/border inherit from the shared // inputDecorationTheme (theme.dart) — only the field-specific // hint/padding need setting here. decoration: InputDecoration( hintText: 'GitHub PAT', hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), ), ), ), const SizedBox(width: 8), ElevatedButton(onPressed: onSave, child: const Text('Save')), ], ); } } class _ExerciseThresholdCard extends StatelessWidget { const _ExerciseThresholdCard({ required this.name, required this.successThreshold, required this.failThreshold, required this.onSuccessChanged, required this.onFailChanged, }); final String name; final int successThreshold; final int failThreshold; final ValueChanged onSuccessChanged; final ValueChanged onFailChanged; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final status = Theme.of(context).extension()!; return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(AppRadius.sm), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name, style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.bold, fontSize: AppTextSize.label, ), ), const SizedBox(height: 10), _ThresholdRow( label: '↑ Increase after N successes', value: successThreshold, color: status.success, onChanged: onSuccessChanged, ), const SizedBox(height: 6), _ThresholdRow( label: '↓ Decrease after N failures', value: failThreshold, color: colorScheme.error, onChanged: onFailChanged, ), ], ), ); } } class _ThresholdRow extends StatelessWidget { const _ThresholdRow({ required this.label, required this.value, required this.color, required this.onChanged, }); final String label; final int value; final Color color; final ValueChanged onChanged; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Row( children: [ Expanded( child: Text( label, style: TextStyle( color: colorScheme.onSurfaceVariant, fontSize: AppTextSize.caption, ), ), ), const SizedBox(width: 8), for (int i = 1; i <= 5; i++) Padding( padding: const EdgeInsets.only(left: 4), child: GestureDetector( onTap: () => onChanged(i), child: Container( width: 32, height: 32, decoration: BoxDecoration( shape: BoxShape.circle, color: i == value ? color : colorScheme.surfaceContainerHighest, ), alignment: Alignment.center, child: Text( '$i', style: TextStyle( // on-fill on the selected (filled) circle. color: i == value ? colorScheme.onPrimary : colorScheme.onSurface, fontWeight: FontWeight.bold, fontSize: AppTextSize.label, ), ), ), ), ), ], ); } } /// Dialog shown during the device flow: displays the user code, opens the /// verification page, and polls until authorized -- popping the token (or /// null if cancelled / failed). class _DeviceCodeDialog extends StatefulWidget { const _DeviceCodeDialog({required this.device, required this.auth}); final DeviceCodeResponse device; final GitHubDeviceAuth auth; @override State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState(); } class _DeviceCodeDialogState extends State<_DeviceCodeDialog> { String? _error; @override void initState() { super.initState(); unawaited(_poll()); } Future _poll() async { try { final token = await widget.auth.pollForToken(widget.device); if (mounted) Navigator.of(context).pop(token); } on Exception catch (e) { if (mounted) setState(() => _error = '$e'); } } Future _openPage() async { await Clipboard.setData(ClipboardData(text: widget.device.userCode)); await launchUrl( Uri.parse(widget.device.verificationUri), mode: LaunchMode.externalApplication, ); } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return AlertDialog( backgroundColor: colorScheme.surfaceContainerHigh, title: Text( 'Authorize on GitHub', style: TextStyle(color: colorScheme.onSurface), ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Enter this code on GitHub:', style: TextStyle(color: colorScheme.onSurfaceVariant), ), const SizedBox(height: 8), SelectableText( widget.device.userCode, style: TextStyle( color: colorScheme.onSurface, fontSize: AppTextSize.title, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 16), if (_error == null) Row( children: [ const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: 12), Expanded( child: Text( 'Waiting for authorization…', style: TextStyle(color: colorScheme.onSurfaceVariant), ), ), ], ) else Text(_error!, style: TextStyle(color: colorScheme.error)), ], ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text( 'Cancel', style: TextStyle(color: colorScheme.onSurfaceVariant), ), ), FilledButton.icon( onPressed: _openPage, icon: const Icon(Icons.open_in_new), label: const Text('Open GitHub & copy code'), ), ], ); } }