From db3d8f827fd9f1a2fc513eef91a6025802b7fba3 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 22 Aug 2026 19:03:24 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG --- .../lib/src/sync_settings_screen.dart | 1 + .../lib/src/sync_settings_sections.dart | 21 ++++++++ .../lib/src/sync_settings_widget.dart | 12 +++++ sync_settings_ui/pubspec.yaml | 2 +- .../sync_settings_screen_google_test.dart | 50 +++++++++++++++++++ 5 files changed, 85 insertions(+), 1 deletion(-) diff --git a/sync_settings_ui/lib/src/sync_settings_screen.dart b/sync_settings_ui/lib/src/sync_settings_screen.dart index 6f7c22d..4bf6389 100644 --- a/sync_settings_ui/lib/src/sync_settings_screen.dart +++ b/sync_settings_ui/lib/src/sync_settings_screen.dart @@ -163,6 +163,7 @@ class _SyncSettingsScreenState extends State { connected: _firebaseConnected, busy: _busy, googleAvailable: _googleAvailable, + googleUnavailableReason: widget.googleUnavailableReason, emailController: _emailController, passwordController: _passwordController, onConnectPassword: _connectFirebase, diff --git a/sync_settings_ui/lib/src/sync_settings_sections.dart b/sync_settings_ui/lib/src/sync_settings_sections.dart index cb00fda..426a0b0 100644 --- a/sync_settings_ui/lib/src/sync_settings_sections.dart +++ b/sync_settings_ui/lib/src/sync_settings_sections.dart @@ -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, diff --git a/sync_settings_ui/lib/src/sync_settings_widget.dart b/sync_settings_ui/lib/src/sync_settings_widget.dart index 0bea519..690f03a 100644 --- a/sync_settings_ui/lib/src/sync_settings_widget.dart +++ b/sync_settings_ui/lib/src/sync_settings_widget.dart @@ -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; diff --git a/sync_settings_ui/pubspec.yaml b/sync_settings_ui/pubspec.yaml index fd88eb2..02b204e 100644 --- a/sync_settings_ui/pubspec.yaml +++ b/sync_settings_ui/pubspec.yaml @@ -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 diff --git a/sync_settings_ui/test/sync_settings_screen_google_test.dart b/sync_settings_ui/test/sync_settings_screen_google_test.dart index 2ec8899..1998d45 100644 --- a/sync_settings_ui/test/sync_settings_screen_google_test.dart +++ b/sync_settings_ui/test/sync_settings_screen_google_test.dart @@ -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( + 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); + }); }); }