Let a host explain an absent Google button instead of hiding it

lyricanki's Linux desktop cannot use google_sign_in (no Linux
implementation) and its loopback replacement is dark until a Desktop OAuth
client exists. Hiding the button there makes the screen silently offer less
on one platform, which reads as a bug.

googleUnavailableReason renders a *disabled* button plus the reason. That is
not the "control that can never succeed" trap: the trap is a live-looking
button that reports a fake cancellation, and a null onPressed cannot be
tapped at all. Null stays the default, so every existing host is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
This commit is contained in:
Krzysztof Rudnicki 2026-08-22 19:03:24 +02:00
parent 79130a6dd7
commit db3d8f827f
5 changed files with 85 additions and 1 deletions

View File

@ -163,6 +163,7 @@ class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
connected: _firebaseConnected,
busy: _busy,
googleAvailable: _googleAvailable,
googleUnavailableReason: widget.googleUnavailableReason,
emailController: _emailController,
passwordController: _passwordController,
onConnectPassword: _connectFirebase,

View File

@ -10,6 +10,7 @@ class _FirebaseSection extends StatelessWidget {
required this.connected,
required this.busy,
required this.googleAvailable,
required this.googleUnavailableReason,
required this.emailController,
required this.passwordController,
required this.onConnectPassword,
@ -20,6 +21,7 @@ class _FirebaseSection extends StatelessWidget {
final bool connected;
final bool busy;
final bool googleAvailable;
final String? googleUnavailableReason;
final TextEditingController emailController;
final TextEditingController passwordController;
final VoidCallback onConnectPassword;
@ -54,6 +56,25 @@ class _FirebaseSection extends StatelessWidget {
),
),
const SizedBox(height: 12),
] else if (googleUnavailableReason != null) ...[
// Disabled, not hidden. A null onPressed cannot be tapped, so this
// is not the "control that can never succeed" trap -- that one
// looks live and reports a fake cancellation. This one says why,
// which beats a screen that silently offers less on one platform.
Align(
alignment: Alignment.centerLeft,
child: FilledButton.icon(
onPressed: null,
icon: const Icon(Icons.account_circle),
label: const Text('Sign in with Google'),
),
),
const SizedBox(height: 4),
Text(
googleUnavailableReason!,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
],
ExpansionTile(
initiallyExpanded: !googleAvailable,

View File

@ -19,6 +19,7 @@ class SyncSettingsScreen extends StatefulWidget {
required this.firebaseFactory,
this.googleFirebaseFactory,
this.googleAvailable,
this.googleUnavailableReason,
this.backup,
super.key,
});
@ -59,6 +60,17 @@ class SyncSettingsScreen extends StatefulWidget {
/// unsupported even though a factory is wired.
final bool? googleAvailable;
/// Why Google sign-in is unavailable here, shown under a *disabled* button.
///
/// Null hides the button entirely, which stays the default. Supplying a
/// reason trades a silently-absent control for one that explains itself --
/// worth it where a platform genuinely cannot support the flow, and where
/// its absence would otherwise read as a bug.
///
/// Not the same as a button that can never succeed: this one cannot be
/// tapped at all, so it can never report a misleading "cancelled".
final String? googleUnavailableReason;
/// Optional local export/import section. Null omits the "Backup" section
/// entirely (diet_guard, wake_alarm have no local backup format).
final BackupSlot? backup;

View File

@ -5,7 +5,7 @@ description: >-
injected closures -- no keystore or platform plugin access lives in this
package itself.
publish_to: 'none'
version: 0.2.0
version: 0.3.0
environment:
sdk: ^3.12.2

View File

@ -141,5 +141,55 @@ void main() {
expect(find.text('Sign in with Google'), findsNothing);
},
);
testWidgets('a reason renders a disabled button that explains itself', (
tester,
) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
googleFirebaseFactory: () async => _fakeClient(),
googleAvailable: false,
googleUnavailableReason: 'Not available on this platform.',
),
),
);
await tester.pumpAndSettle();
expect(find.text('Sign in with Google'), findsOneWidget);
expect(find.text('Not available on this platform.'), findsOneWidget);
// Disabled, so it cannot report a misleading "cancelled".
final button = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Sign in with Google'),
);
expect(button.onPressed, isNull);
});
testWidgets('no reason keeps the button hidden, as before', (
tester,
) async {
await tester.pumpWidget(
_wrap(
SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
googleFirebaseFactory: () async => _fakeClient(),
googleAvailable: false,
),
),
);
await tester.pumpAndSettle();
expect(find.text('Sign in with Google'), findsNothing);
});
});
}