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); + }); }); }