From 2fd6e6825868e0a4044b46fe939e8a69df020d88 Mon Sep 17 00:00:00 2001 From: Seenu K Date: Wed, 12 Aug 2026 18:29:45 +0530 Subject: [PATCH 1/2] Update to make web first class citizen --- assets/google/README.md | 10 + assets/google/sign_in_with_google_dark_2x.png | Bin 0 -> 7965 bytes .../google/sign_in_with_google_light_2x.png | Bin 0 -> 7760 bytes example/web/icons/eigen-mark-192.png | Bin 0 -> 5828 bytes example/web/icons/eigen-mark-512.png | Bin 0 -> 15261 bytes example/web/icons/eigen-mark-maskable-512.png | Bin 0 -> 11365 bytes example/web/icons/eigen-mark-maskable.svg | 8 + example/web/icons/eigen-mark.svg | 4 + example/web/index.html | 6 +- example/web/manifest.json | 29 +- lib/app_runner.dart | 25 + lib/core/adaptive/adaptive_layout.dart | 114 +++++ lib/core/licenses/bundled_font_licenses.dart | 21 + .../providers/navigation_providers.dart | 8 +- .../navigation/widgets/app_route_title.dart | 62 +++ .../navigation/widgets/not_found_screen.dart | 62 +++ .../navigation/widgets/shell_scaffold.dart | 287 ++++++++---- lib/core/theme/app_semantic_colors.dart | 190 ++++++++ lib/core/theme/app_theme.dart | 44 +- lib/core/utils/deep_links.dart | 5 +- .../presentation/screens/about_screen.dart | 92 ++-- .../presentation/screens/login_screen.dart | 114 +++-- .../widgets/branded_google_button.dart | 71 +++ .../game/presentation/extensions/game_ui.dart | 120 ++++- .../screens/game_screen_pre_game.dart | 1 + .../presentation/screens/history_screen.dart | 155 +++++-- .../presentation/screens/lobby_screen.dart | 438 ++++++++++++------ .../presentation/widgets/budget_clock.dart | 30 +- .../presentation/widgets/new_game_dialog.dart | 208 ++++----- .../widgets/play_vs_bot_dialog.dart | 153 +++--- .../presentation/widgets/timing_selector.dart | 27 +- .../presentation/screens/home_screen.dart | 279 ++++++----- .../presentation/screens/profile_screen.dart | 159 +++---- .../presentation/extensions/rating_ui.dart | 20 + .../presentation/widgets/player_ratings.dart | 4 +- .../presentation/screens/settings_screen.dart | 253 +++++----- .../social/presentation/social_screen.dart | 254 ++++++++-- .../presentation/widgets/friend_buttons.dart | 51 +- .../widgets/player_profile_sheet.dart | 195 ++++---- .../widgets/adaptive_single_choice.dart | 113 +++++ lib/shared/widgets/made_by_credit.dart | 82 ++-- lib/shared/widgets/player_avatar.dart | 59 ++- lib/shared/widgets/status_banner.dart | 38 +- pubspec.yaml | 3 + test/core/adaptive/adaptive_layout_test.dart | 111 +++++ test/core/config/web_metadata_test.dart | 35 ++ .../core/navigation/app_route_title_test.dart | 84 ++++ .../navigation/not_found_screen_test.dart | 25 + .../shell_scaffold_adaptive_test.dart | 64 +++ test/core/theme/app_semantic_colors_test.dart | 82 ++++ test/core/theme/app_theme_test.dart | 69 +++ .../auth/branded_google_button_test.dart | 78 ++++ .../auth/login_screen_adaptive_test.dart | 61 +++ .../game/presentation/game_ui_test.dart | 76 +++ .../presentation/new_game_dialog_test.dart | 52 +++ .../rating/presentation/rating_ui_test.dart | 35 ++ .../friend_buttons_accessibility_test.dart | 35 ++ .../player_profile_panel_test.dart | 190 ++++++++ .../presentation/social_url_tabs_test.dart | 162 +++++++ .../widgets/adaptive_single_choice_test.dart | 106 +++++ test/shared/widgets/made_by_credit_test.dart | 52 ++- test/shared/widgets/player_avatar_test.dart | 134 ++++++ test/shared/widgets/status_banner_test.dart | 33 ++ 63 files changed, 4165 insertions(+), 1113 deletions(-) create mode 100644 assets/google/README.md create mode 100644 assets/google/sign_in_with_google_dark_2x.png create mode 100644 assets/google/sign_in_with_google_light_2x.png create mode 100644 example/web/icons/eigen-mark-192.png create mode 100644 example/web/icons/eigen-mark-512.png create mode 100644 example/web/icons/eigen-mark-maskable-512.png create mode 100644 example/web/icons/eigen-mark-maskable.svg create mode 100644 example/web/icons/eigen-mark.svg create mode 100644 lib/core/adaptive/adaptive_layout.dart create mode 100644 lib/core/licenses/bundled_font_licenses.dart create mode 100644 lib/core/navigation/widgets/app_route_title.dart create mode 100644 lib/core/navigation/widgets/not_found_screen.dart create mode 100644 lib/core/theme/app_semantic_colors.dart create mode 100644 lib/features/auth/presentation/widgets/branded_google_button.dart create mode 100644 lib/shared/widgets/adaptive_single_choice.dart create mode 100644 test/core/adaptive/adaptive_layout_test.dart create mode 100644 test/core/config/web_metadata_test.dart create mode 100644 test/core/navigation/app_route_title_test.dart create mode 100644 test/core/navigation/not_found_screen_test.dart create mode 100644 test/core/navigation/shell_scaffold_adaptive_test.dart create mode 100644 test/core/theme/app_semantic_colors_test.dart create mode 100644 test/features/auth/branded_google_button_test.dart create mode 100644 test/features/auth/login_screen_adaptive_test.dart create mode 100644 test/features/game/presentation/game_ui_test.dart create mode 100644 test/features/game/presentation/new_game_dialog_test.dart create mode 100644 test/features/rating/presentation/rating_ui_test.dart create mode 100644 test/features/social/presentation/friend_buttons_accessibility_test.dart create mode 100644 test/features/social/presentation/player_profile_panel_test.dart create mode 100644 test/features/social/presentation/social_url_tabs_test.dart create mode 100644 test/shared/widgets/adaptive_single_choice_test.dart create mode 100644 test/shared/widgets/player_avatar_test.dart create mode 100644 test/shared/widgets/status_banner_test.dart diff --git a/assets/google/README.md b/assets/google/README.md new file mode 100644 index 0000000..2005787 --- /dev/null +++ b/assets/google/README.md @@ -0,0 +1,10 @@ +# Google sign-in assets + +The two PNGs in this directory are Google's pre-approved Android and web +"Sign in with Google" pill buttons at 2x resolution. They were downloaded from +the official branding bundle on 2026-08-12: + + + +Do not recolor, redraw, crop, or change the aspect ratio. When Google updates +its branding requirements, replace these files from the official bundle. diff --git a/assets/google/sign_in_with_google_dark_2x.png b/assets/google/sign_in_with_google_dark_2x.png new file mode 100644 index 0000000000000000000000000000000000000000..90b406a69022b8b5d3ff11c43fe688e94d5229e4 GIT binary patch literal 7965 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 Nd{g5_g`8Q){{cjX?+O3_ literal 0 HcmV?d00001 diff --git a/example/web/icons/eigen-mark-maskable-512.png b/example/web/icons/eigen-mark-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..0a2bbdfc52ed899482196b645fbcafa8fdecb191 GIT binary patch literal 11365 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 literal 0 HcmV?d00001 diff --git a/example/web/icons/eigen-mark-maskable.svg b/example/web/icons/eigen-mark-maskable.svg new file mode 100644 index 0000000..762e4ef --- /dev/null +++ b/example/web/icons/eigen-mark-maskable.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/example/web/icons/eigen-mark.svg b/example/web/icons/eigen-mark.svg new file mode 100644 index 0000000..5782e78 --- /dev/null +++ b/example/web/icons/eigen-mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/example/web/index.html b/example/web/index.html index 082ed0d..41ebfbd 100644 --- a/example/web/index.html +++ b/example/web/index.html @@ -1,13 +1,17 @@ - + + + + + Rock Paper Scissors diff --git a/example/web/manifest.json b/example/web/manifest.json index 5613fe7..eea87d7 100644 --- a/example/web/manifest.json +++ b/example/web/manifest.json @@ -1,12 +1,39 @@ { "name": "Rock Paper Scissors", "short_name": "RPS", + "id": ".", "start_url": ".", + "scope": ".", "display": "standalone", "background_color": "#00695c", "theme_color": "#00695c", "description": "The EigenInteractive reference game", "orientation": "any", "prefer_related_applications": false, - "icons": [] + "icons": [ + { + "src": "icons/eigen-mark-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icons/eigen-mark-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icons/eigen-mark-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/eigen-mark.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] } diff --git a/lib/app_runner.dart b/lib/app_runner.dart index 3bf3719..6af45d1 100644 --- a/lib/app_runner.dart +++ b/lib/app_runner.dart @@ -4,18 +4,23 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:eigen_flutter/core/api/retry_policy.dart'; import 'package:eigen_flutter/core/config/app_config.dart'; import 'package:eigen_flutter/core/game/game_module.dart'; +import 'package:eigen_flutter/core/licenses/bundled_font_licenses.dart'; import 'package:eigen_flutter/core/navigation/providers/navigation_providers.dart'; +import 'package:eigen_flutter/core/navigation/widgets/app_route_title.dart'; import 'package:eigen_flutter/core/navigation/url_strategy.dart'; import 'package:eigen_flutter/core/startup/app_startup.dart'; import 'package:eigen_flutter/core/theme/app_theme.dart'; import 'package:eigen_flutter/core/theme/theme_provider.dart'; import 'package:eigen_flutter/features/game/providers/game_providers.dart'; +SemanticsHandle? _webSemanticsHandle; + /// Boots a whitelabel game app on the engine. /// /// This is the framework's "app as a library" entry point: each game app's @@ -38,6 +43,13 @@ Future runEngineApp({ config.engine.validate(isWeb: kIsWeb); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); + if (kIsWeb) { + // Flutter web otherwise waits for its invisible opt-in control before + // exposing the semantics tree. Keep the handle alive for the application + // lifetime so browser assistive technology works from the first frame. + _webSemanticsHandle ??= SemanticsBinding.instance.ensureSemantics(); + } + registerBundledFontLicenses(); configureUrlStrategy(); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); @@ -115,8 +127,21 @@ class MyApp extends ConsumerWidget { branding.seedColor, display: branding.displayFontFamily, ), + highContrastTheme: AppTheme.highContrastLight( + branding.seedColor, + display: branding.displayFontFamily, + ), + highContrastDarkTheme: AppTheme.highContrastDark( + branding.seedColor, + display: branding.displayFontFamily, + ), themeMode: themeMode, routerConfig: router, + builder: (context, child) => AppRouteTitle( + router: router, + appName: branding.appName, + child: child ?? const SizedBox.shrink(), + ), ); } } diff --git a/lib/core/adaptive/adaptive_layout.dart b/lib/core/adaptive/adaptive_layout.dart new file mode 100644 index 0000000..a174fa3 --- /dev/null +++ b/lib/core/adaptive/adaptive_layout.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; + +/// Material window width classes derived from the space available to a widget. +enum AppWindowClass { + compact, + medium, + expanded, + large, + extraLarge; + + /// Classifies a logical-pixel [width] using the Material window breakpoints. + static AppWindowClass fromWidth(double width) => switch (width) { + < 600 => compact, + < 840 => medium, + < 1200 => expanded, + < 1600 => large, + _ => extraLarge, + }; + + bool get isCompact => this == compact; + + bool get isAtLeastMedium => index >= medium.index; + + bool get isAtLeastExpanded => index >= expanded.index; +} + +/// Builds from local layout constraints and their Material window width class. +class AdaptiveLayoutBuilder extends StatelessWidget { + const AdaptiveLayoutBuilder({required this.builder, super.key}); + + final Widget Function( + BuildContext context, + BoxConstraints constraints, + AppWindowClass windowClass, + ) + builder; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) => builder( + context, + constraints, + AppWindowClass.fromWidth(constraints.maxWidth), + ), + ); + } +} + +/// Centers a child and prevents readable content from stretching indefinitely. +/// +/// This widget deliberately does not add padding: scrollable children should +/// own their padding so scroll bars and overscroll effects stay at the pane's +/// edge, while fixed content can add padding inside the pane. +class ConstrainedContentPane extends StatelessWidget { + const ConstrainedContentPane({ + required this.child, + required this.maxWidth, + this.alignment = Alignment.topCenter, + super.key, + }); + + final Widget child; + final double maxWidth; + final AlignmentGeometry alignment; + + @override + Widget build(BuildContext context) { + return Align( + alignment: alignment, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: SizedBox(width: double.infinity, child: child), + ), + ); + } +} + +/// A max-extent grid delegate that stays single-column below [twoColumnWidth]. +/// +/// The switch is based on this grid's actual width, not a device or platform. +/// Above the threshold, [maxCrossAxisExtent] lets additional columns appear as +/// the window grows instead of hard-coding a column count. +SliverGridDelegate responsiveCardGridDelegate({ + required double availableWidth, + required double maxCrossAxisExtent, + required double mainAxisExtent, + double twoColumnWidth = 840, + double spacing = 12, +}) { + return SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: availableWidth < twoColumnWidth + ? availableWidth + : maxCrossAxisExtent, + mainAxisExtent: mainAxisExtent, + mainAxisSpacing: spacing, + crossAxisSpacing: spacing, + ); +} + +/// Whether fixed-height cards have enough room to use a multi-column grid. +/// +/// At larger accessibility text sizes, cards return to an intrinsically sized +/// list so text can reflow vertically without being clipped by a grid extent. +bool shouldUseCardGrid({ + required AppWindowClass windowClass, + required TextScaler textScaler, + double maximumTextScale = 1.2, +}) { + const referenceFontSize = 16.0; + final effectiveScale = + textScaler.scale(referenceFontSize) / referenceFontSize; + return windowClass.isAtLeastExpanded && effectiveScale <= maximumTextScale; +} diff --git a/lib/core/licenses/bundled_font_licenses.dart b/lib/core/licenses/bundled_font_licenses.dart new file mode 100644 index 0000000..6d2c1f1 --- /dev/null +++ b/lib/core/licenses/bundled_font_licenses.dart @@ -0,0 +1,21 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Registers the notices for the font files bundled by this package. +/// +/// Package assets use a package-qualified key when read from an application +/// that depends on `eigen_flutter`, matching the package-qualified font family +/// names used by the theme. +void registerBundledFontLicenses() { + LicenseRegistry.addLicense(() async* { + final inter = await rootBundle.loadString( + 'packages/eigen_flutter/fonts/OFL-Inter.txt', + ); + yield LicenseEntryWithLineBreaks(const ['Inter'], inter); + + final spaceGrotesk = await rootBundle.loadString( + 'packages/eigen_flutter/fonts/OFL-SpaceGrotesk.txt', + ); + yield LicenseEntryWithLineBreaks(const ['Space Grotesk'], spaceGrotesk); + }); +} diff --git a/lib/core/navigation/providers/navigation_providers.dart b/lib/core/navigation/providers/navigation_providers.dart index 84689b5..8da7846 100644 --- a/lib/core/navigation/providers/navigation_providers.dart +++ b/lib/core/navigation/providers/navigation_providers.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:eigen_flutter/core/navigation/router/app_router.dart'; import 'package:eigen_flutter/core/navigation/utils/stream_listenable.dart'; +import 'package:eigen_flutter/core/navigation/widgets/not_found_screen.dart'; import 'package:eigen_flutter/features/auth/providers/auth_providers.dart'; part 'navigation_providers.g.dart'; @@ -56,11 +57,8 @@ GoRouter goRouter(Ref ref) { return null; }, - onException: (context, state, router) { - // Unknown paths (e.g. a deep link the OS re-routes into the app for a - // path we don't handle) go home rather than crashing with GoException. - router.go('/home'); - }, + errorBuilder: (context, state) => + NotFoundScreen(location: state.uri.toString()), routes: appRoutes, observers: [ FirebaseAnalyticsObserver(analytics: FirebaseAnalytics.instance), diff --git a/lib/core/navigation/widgets/app_route_title.dart b/lib/core/navigation/widgets/app_route_title.dart new file mode 100644 index 0000000..a103807 --- /dev/null +++ b/lib/core/navigation/widgets/app_route_title.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// Supplies one route-aware browser/window title above the entire router. +/// +/// Stateful shell branches stay mounted while they are offstage. A [Title] +/// inside each branch therefore does not rebuild when an existing branch is +/// reselected. Listening to the router's route-information provider here keeps +/// browser tabs and Android's app switcher in sync for branch changes, pushes, +/// pops, and browser Back/Forward navigation. +class AppRouteTitle extends StatelessWidget { + /// Creates a title that follows [router]'s current URI. + const AppRouteTitle({ + super.key, + required this.router, + required this.appName, + required this.child, + }); + + final GoRouter router; + final String appName; + + final Widget child; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: router.routeInformationProvider, + builder: (context, _) { + final pageName = appPageNameForUri( + router.routeInformationProvider.value.uri, + ); + return Title( + key: const ValueKey('app-route-title'), + title: '$pageName · $appName', + color: Theme.of(context).colorScheme.primary, + child: child, + ); + }, + ); + } +} + +/// The stable, user-facing page name for a routed [uri]. +@visibleForTesting +String appPageNameForUri(Uri uri) { + final path = uri.path; + if (path.endsWith('/replay') && path.startsWith('/game/')) return 'Replay'; + if (path.startsWith('/game/')) return 'Game'; + if (path.startsWith('/join/')) return 'Join game'; + if (path == '/settings/profile') return 'Profile'; + return switch (path) { + '/' || '/home' => 'Home', + '/login' => 'Sign in', + '/lobby' => 'Lobby', + '/history' => 'History', + '/social' => 'Social', + '/about' => 'About', + '/settings' => 'Settings', + _ => 'Page not found', + }; +} diff --git a/lib/core/navigation/widgets/not_found_screen.dart b/lib/core/navigation/widgets/not_found_screen.dart new file mode 100644 index 0000000..ff69850 --- /dev/null +++ b/lib/core/navigation/widgets/not_found_screen.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// Friendly destination for an unknown or malformed browser URL. +class NotFoundScreen extends StatelessWidget { + /// Creates a not-found screen for [location]. + const NotFoundScreen({super.key, required this.location}); + + /// URL that could not be matched. + final String location; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: SelectionArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.explore_off_outlined, + size: 72, + color: colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + 'Page not found', + style: textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + 'There is no page at $location.', + style: textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: () => context.go('/home'), + icon: const Icon(Icons.home_outlined), + label: const Text('Go home'), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/core/navigation/widgets/shell_scaffold.dart b/lib/core/navigation/widgets/shell_scaffold.dart index f20e584..a7dd010 100644 --- a/lib/core/navigation/widgets/shell_scaffold.dart +++ b/lib/core/navigation/widgets/shell_scaffold.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:eigen_flutter/core/adaptive/adaptive_layout.dart'; import 'package:eigen_flutter/core/config/app_config.dart'; import 'package:eigen_flutter/core/connectivity/connectivity_provider.dart'; import 'package:eigen_flutter/shared/widgets/status_banner.dart'; @@ -56,110 +57,181 @@ class ShellScaffold extends ConsumerWidget { // [soloPlayAvailableProvider]. Most deployments with no bots get an empty // catalog → no extra FAB. final canPlaySolo = ref.watch(soloPlayAvailableProvider); - final colorScheme = Theme.of(context).colorScheme; final index = navigationShell.currentIndex; final branch = _ShellBranch.values[index]; final title = branch.title; - return Scaffold( - appBar: AppBar( - title: title.isEmpty ? null : Text(title), - backgroundColor: colorScheme.surface, - foregroundColor: colorScheme.onSurface, - elevation: 0, - scrolledUnderElevation: 0, - ), - drawer: NavigationDrawer( - selectedIndex: index, - onDestinationSelected: (int i) { - Navigator.of(context).pop(); - navigationShell.goBranch(i, initialLocation: i == index); - }, - children: [ - const _DrawerHeader(), - const NavigationDrawerDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home_rounded), - label: Text('Home'), - ), - const NavigationDrawerDestination( - icon: Icon(Icons.groups_outlined), - selectedIcon: Icon(Icons.groups_rounded), - label: Text('Lobby'), - ), - const NavigationDrawerDestination( - icon: Icon(Icons.history_outlined), - selectedIcon: Icon(Icons.history_rounded), - label: Text('History'), - ), - // Social is registered-only. For guests the destination stays visible - // but disabled (greyed, non-tappable): the same visible-but-disabled - // treatment rated games get in the lobby. enabled:false also keeps the - // tap from firing onDestinationSelected, so branch indices stay 1:1. - NavigationDrawerDestination( - enabled: !isGuest, - icon: const _SocialDrawerIcon(selected: false), - selectedIcon: const _SocialDrawerIcon(selected: true), - label: const Text('Social'), - ), - const NavigationDrawerDestination( - icon: Icon(Icons.info_outline), - selectedIcon: Icon(Icons.info_rounded), - label: Text('About'), - ), - const NavigationDrawerDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings_rounded), - label: Text('Settings'), - ), - const _SignOutButton(), - ], - ), - floatingActionButton: index == 0 - ? Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (canPlaySolo) ...[ - FloatingActionButton.extended( - heroTag: 'newSoloGame', - onPressed: () => showDialog( - context: context, - useSafeArea: true, - builder: (_) => const PlayVsBotDialog(), + void selectBranch(int i) { + navigationShell.goBranch(i, initialLocation: i == index); + } + + void showNewGame() => showDialog( + context: context, + useSafeArea: true, + builder: (_) => const NewGameDialog(), + ); + + return AdaptiveLayoutBuilder( + builder: (context, constraints, windowClass) { + final compact = windowClass.isCompact; + final expandedRail = windowClass.isAtLeastExpanded; + final content = Column( + children: [ + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: isOffline + ? const _OfflineBanner() + : const SizedBox.shrink(), + ), + Expanded(child: SafeArea(child: navigationShell)), + ], + ); + + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: compact, + title: title.isEmpty ? null : Text(title), + actions: index == 0 && canPlaySolo + ? [ + IconButton( + onPressed: () => showDialog( + context: context, + useSafeArea: true, + builder: (_) => const PlayVsBotDialog(), + ), + icon: const Icon(Icons.smart_toy_outlined), + tooltip: 'New Solo Game', ), - icon: const Icon(Icons.smart_toy_outlined), - label: const Text('New Solo Game'), - ), - const SizedBox(height: 12), - ], - FloatingActionButton.extended( + ] + : null, + ), + drawer: compact + ? NavigationDrawer( + selectedIndex: index, + onDestinationSelected: (int i) { + Navigator.of(context).pop(); + selectBranch(i); + }, + children: [ + const _DrawerHeader(), + ..._drawerDestinations(isGuest: isGuest), + const _SignOutButton(), + ], + ) + : null, + floatingActionButton: index == 0 + ? FloatingActionButton.extended( heroTag: 'newGame', - onPressed: () => showDialog( - context: context, - useSafeArea: true, - builder: (_) => const NewGameDialog(), - ), + onPressed: showNewGame, icon: const Icon(Icons.add), label: const Text('New Game'), + ) + : null, + body: compact + ? content + : Row( + children: [ + SafeArea( + right: false, + child: NavigationRail( + extended: expandedRail, + selectedIndex: index, + onDestinationSelected: selectBranch, + labelType: expandedRail + ? NavigationRailLabelType.none + : NavigationRailLabelType.all, + groupAlignment: -1, + scrollable: true, + leadingAtTop: false, + leading: expandedRail + ? const _RailHeader() + : const SizedBox(height: 16), + trailing: const _RailSignOutButton(), + trailingAtBottom: true, + destinations: _railDestinations(isGuest: isGuest), + ), + ), + const VerticalDivider(width: 1), + Expanded(child: content), + ], ), - ], - ) - : null, - body: Column( - children: [ - AnimatedSize( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - child: isOffline ? const _OfflineBanner() : const SizedBox.shrink(), - ), - Expanded(child: SafeArea(child: navigationShell)), - ], - ), + ); + }, ); } } +List _drawerDestinations({ + required bool isGuest, +}) => [ + const NavigationDrawerDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home_rounded), + label: Text('Home'), + ), + const NavigationDrawerDestination( + icon: Icon(Icons.groups_outlined), + selectedIcon: Icon(Icons.groups_rounded), + label: Text('Lobby'), + ), + const NavigationDrawerDestination( + icon: Icon(Icons.history_outlined), + selectedIcon: Icon(Icons.history_rounded), + label: Text('History'), + ), + NavigationDrawerDestination( + enabled: !isGuest, + icon: const _SocialDrawerIcon(selected: false), + selectedIcon: const _SocialDrawerIcon(selected: true), + label: const Text('Social'), + ), + const NavigationDrawerDestination( + icon: Icon(Icons.info_outline), + selectedIcon: Icon(Icons.info_rounded), + label: Text('About'), + ), + const NavigationDrawerDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings_rounded), + label: Text('Settings'), + ), +]; + +List _railDestinations({required bool isGuest}) => [ + const NavigationRailDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home_rounded), + label: Text('Home'), + ), + const NavigationRailDestination( + icon: Icon(Icons.groups_outlined), + selectedIcon: Icon(Icons.groups_rounded), + label: Text('Lobby'), + ), + const NavigationRailDestination( + icon: Icon(Icons.history_outlined), + selectedIcon: Icon(Icons.history_rounded), + label: Text('History'), + ), + NavigationRailDestination( + disabled: isGuest, + icon: const _SocialDrawerIcon(selected: false), + selectedIcon: const _SocialDrawerIcon(selected: true), + label: const Text('Social'), + ), + const NavigationRailDestination( + icon: Icon(Icons.info_outline), + selectedIcon: Icon(Icons.info_rounded), + label: Text('About'), + ), + const NavigationRailDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings_rounded), + label: Text('Settings'), + ), +]; + /// Slim banner shown when the device has no network connectivity. class _OfflineBanner extends StatelessWidget { const _OfflineBanner(); @@ -197,6 +269,23 @@ class _DrawerHeader extends ConsumerWidget { } } +class _RailHeader extends ConsumerWidget { + const _RailHeader(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 24), + child: Text( + ref.watch(appConfigProvider).branding.appName, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + } +} + /// Social icon with a badge showing the number of incoming friend requests. class _SocialDrawerIcon extends ConsumerWidget { const _SocialDrawerIcon({required this.selected}); @@ -236,3 +325,19 @@ class _SignOutButton extends ConsumerWidget { ); } } + +class _RailSignOutButton extends ConsumerWidget { + const _RailSignOutButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: IconButton( + onPressed: () => ref.read(authControllerProvider.notifier).signOut(), + icon: const Icon(Icons.logout), + tooltip: 'Sign Out', + ), + ); + } +} diff --git a/lib/core/theme/app_semantic_colors.dart b/lib/core/theme/app_semantic_colors.dart new file mode 100644 index 0000000..6a2fb3f --- /dev/null +++ b/lib/core/theme/app_semantic_colors.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; + +/// Product-level colors for meanings that Material's [ColorScheme] does not +/// define. +/// +/// Material reserves its primary, secondary, and tertiary roles for branding +/// and visual emphasis. They are therefore unsuitable for stable meanings such +/// as success, warning, and information: those meanings would change whenever +/// an app supplies a different seed. This extension keeps those roles semantic +/// while retaining paired foreground and container colors for accessible text +/// and icons. +@immutable +class AppSemanticColors extends ThemeExtension { + const AppSemanticColors({ + required this.success, + required this.onSuccess, + required this.successContainer, + required this.onSuccessContainer, + required this.warning, + required this.onWarning, + required this.warningContainer, + required this.onWarningContainer, + required this.info, + required this.onInfo, + required this.infoContainer, + required this.onInfoContainer, + }); + + /// Builds the semantic palette for [brightness]. + /// + /// These values deliberately come from Flutter's named Material swatches, + /// rather than from the app's brand seed, so their meaning remains stable + /// across white-label apps. Every role has a corresponding `on` role. + factory AppSemanticColors.forBrightness( + Brightness brightness, { + bool highContrast = false, + }) { + if (brightness == Brightness.dark) { + return highContrast + ? AppSemanticColors._highContrastDark() + : AppSemanticColors._dark(); + } + return highContrast + ? AppSemanticColors._highContrastLight() + : AppSemanticColors._light(); + } + + factory AppSemanticColors._light() => AppSemanticColors( + success: Colors.green.shade800, + onSuccess: Colors.white, + successContainer: Colors.green.shade100, + onSuccessContainer: Colors.green.shade900, + warning: Colors.deepOrange.shade900, + onWarning: Colors.white, + warningContainer: Colors.deepOrange.shade50, + onWarningContainer: Colors.deepOrange.shade900, + info: Colors.blue.shade800, + onInfo: Colors.white, + infoContainer: Colors.blue.shade100, + onInfoContainer: Colors.blue.shade900, + ); + + factory AppSemanticColors._dark() => AppSemanticColors( + success: Colors.green.shade300, + onSuccess: Colors.black, + successContainer: Colors.green.shade900, + onSuccessContainer: Colors.green.shade100, + warning: Colors.deepOrange.shade300, + onWarning: Colors.black, + warningContainer: Colors.deepOrange.shade900, + onWarningContainer: Colors.deepOrange.shade50, + info: Colors.blue.shade300, + onInfo: Colors.black, + infoContainer: Colors.blue.shade900, + onInfoContainer: Colors.blue.shade100, + ); + + factory AppSemanticColors._highContrastLight() => AppSemanticColors( + success: Colors.green.shade900, + onSuccess: Colors.white, + successContainer: Colors.green.shade50, + onSuccessContainer: Colors.black, + warning: Colors.deepOrange.shade900, + onWarning: Colors.white, + warningContainer: Colors.deepOrange.shade50, + onWarningContainer: Colors.black, + info: Colors.blue.shade900, + onInfo: Colors.white, + infoContainer: Colors.blue.shade50, + onInfoContainer: Colors.black, + ); + + factory AppSemanticColors._highContrastDark() => AppSemanticColors( + success: Colors.green.shade100, + onSuccess: Colors.black, + successContainer: Colors.green.shade900, + onSuccessContainer: Colors.white, + warning: Colors.deepOrange.shade100, + onWarning: Colors.black, + warningContainer: Colors.deepOrange.shade900, + onWarningContainer: Colors.white, + info: Colors.blue.shade100, + onInfo: Colors.black, + infoContainer: Colors.blue.shade900, + onInfoContainer: Colors.white, + ); + + /// The semantic colors from the nearest [Theme]. + static AppSemanticColors of(BuildContext context) => + Theme.of(context).extension()!; + + final Color success; + final Color onSuccess; + final Color successContainer; + final Color onSuccessContainer; + + final Color warning; + final Color onWarning; + final Color warningContainer; + final Color onWarningContainer; + + final Color info; + final Color onInfo; + final Color infoContainer; + final Color onInfoContainer; + + @override + AppSemanticColors copyWith({ + Color? success, + Color? onSuccess, + Color? successContainer, + Color? onSuccessContainer, + Color? warning, + Color? onWarning, + Color? warningContainer, + Color? onWarningContainer, + Color? info, + Color? onInfo, + Color? infoContainer, + Color? onInfoContainer, + }) => AppSemanticColors( + success: success ?? this.success, + onSuccess: onSuccess ?? this.onSuccess, + successContainer: successContainer ?? this.successContainer, + onSuccessContainer: onSuccessContainer ?? this.onSuccessContainer, + warning: warning ?? this.warning, + onWarning: onWarning ?? this.onWarning, + warningContainer: warningContainer ?? this.warningContainer, + onWarningContainer: onWarningContainer ?? this.onWarningContainer, + info: info ?? this.info, + onInfo: onInfo ?? this.onInfo, + infoContainer: infoContainer ?? this.infoContainer, + onInfoContainer: onInfoContainer ?? this.onInfoContainer, + ); + + @override + AppSemanticColors lerp(covariant AppSemanticColors? other, double t) { + if (other == null) return this; + return AppSemanticColors( + success: Color.lerp(success, other.success, t)!, + onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!, + successContainer: Color.lerp( + successContainer, + other.successContainer, + t, + )!, + onSuccessContainer: Color.lerp( + onSuccessContainer, + other.onSuccessContainer, + t, + )!, + warning: Color.lerp(warning, other.warning, t)!, + onWarning: Color.lerp(onWarning, other.onWarning, t)!, + warningContainer: Color.lerp( + warningContainer, + other.warningContainer, + t, + )!, + onWarningContainer: Color.lerp( + onWarningContainer, + other.onWarningContainer, + t, + )!, + info: Color.lerp(info, other.info, t)!, + onInfo: Color.lerp(onInfo, other.onInfo, t)!, + infoContainer: Color.lerp(infoContainer, other.infoContainer, t)!, + onInfoContainer: Color.lerp(onInfoContainer, other.onInfoContainer, t)!, + ); + } +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 775aa74..cc95b9c 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:eigen_flutter/core/theme/app_semantic_colors.dart'; /// Minimal app theme configuration using Material 3. /// @@ -21,10 +22,10 @@ abstract final class AppTheme { static const SnackBarThemeData _snackBarTheme = SnackBarThemeData( behavior: SnackBarBehavior.floating, + width: 480, ); - static final Map<(Color, String), ThemeData> _lightCache = {}; - static final Map<(Color, String), ThemeData> _darkCache = {}; + static final Map<(Color, String, Brightness, bool), ThemeData> _cache = {}; /// The Material 3 text theme with [display] on the display and headline /// roles and Inter everywhere else. @@ -53,16 +54,21 @@ abstract final class AppTheme { Color seedColor, String display, Brightness brightness, + bool highContrast, ) { final theme = ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: seedColor, brightness: brightness, + contrastLevel: highContrast ? 1 : 0, ), // Inter is the default for everything, including the widgets that build // their own text styles rather than reading the text theme. fontFamily: inter, snackBarTheme: _snackBarTheme, + extensions: [ + AppSemanticColors.forBrightness(brightness, highContrast: highContrast), + ], ); return theme.copyWith(textTheme: textTheme(theme.textTheme, display)); } @@ -70,16 +76,42 @@ abstract final class AppTheme { /// Light theme for [seedColor], with [display] on the display and headline /// roles. Cached per pair. static ThemeData light(Color seedColor, {String display = spaceGrotesk}) => - _lightCache.putIfAbsent(( + _cache.putIfAbsent(( seedColor, display, - ), () => _build(seedColor, display, Brightness.light)); + Brightness.light, + false, + ), () => _build(seedColor, display, Brightness.light, false)); /// Dark theme for [seedColor], with [display] on the display and headline /// roles. Cached per pair. static ThemeData dark(Color seedColor, {String display = spaceGrotesk}) => - _darkCache.putIfAbsent(( + _cache.putIfAbsent(( seedColor, display, - ), () => _build(seedColor, display, Brightness.dark)); + Brightness.dark, + false, + ), () => _build(seedColor, display, Brightness.dark, false)); + + /// High-contrast light theme for [seedColor]. + static ThemeData highContrastLight( + Color seedColor, { + String display = spaceGrotesk, + }) => _cache.putIfAbsent(( + seedColor, + display, + Brightness.light, + true, + ), () => _build(seedColor, display, Brightness.light, true)); + + /// High-contrast dark theme for [seedColor]. + static ThemeData highContrastDark( + Color seedColor, { + String display = spaceGrotesk, + }) => _cache.putIfAbsent(( + seedColor, + display, + Brightness.dark, + true, + ), () => _build(seedColor, display, Brightness.dark, true)); } diff --git a/lib/core/utils/deep_links.dart b/lib/core/utils/deep_links.dart index adc62d5..dd4754f 100644 --- a/lib/core/utils/deep_links.dart +++ b/lib/core/utils/deep_links.dart @@ -27,7 +27,8 @@ Uri? gameReplayLink(String gameId, {required String? appHost}) => /// host as the app's deep links. That is safe because the app's App Links /// intent-filter is scoped to the `/join` and `/game` prefixes (see the /// [deep-link guide](https://eigeninteractive.com/docs/ship-it/deep-links)), so -/// legal paths are never intercepted and open in the browser. Opened with -/// `LaunchMode.inAppBrowserView` as belt and braces. +/// legal paths are never intercepted and open in the browser. UI callers use +/// `Link` so browsers retain native link semantics and Android delegates the +/// external destination to the platform. Uri? legalPageUrl(String path, {required String? appHost}) => appHost != null ? Uri.https(appHost, path) : null; diff --git a/lib/features/about/presentation/screens/about_screen.dart b/lib/features/about/presentation/screens/about_screen.dart index c1fb6fc..6443fe0 100644 --- a/lib/features/about/presentation/screens/about_screen.dart +++ b/lib/features/about/presentation/screens/about_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:eigen_flutter/core/config/app_config.dart'; +import 'package:eigen_flutter/core/adaptive/adaptive_layout.dart'; import 'package:eigen_flutter/core/utils/package_info_provider.dart'; import 'package:eigen_flutter/shared/widgets/made_by_credit.dart'; import 'package:eigen_flutter/features/game/providers/game_providers.dart'; @@ -19,26 +20,45 @@ class AboutScreen extends ConsumerWidget { final appName = ref.watch(appConfigProvider).branding.appName; final rules = ref.watch(currentGameModuleProvider).buildRules(context); - return ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - children: [ - _AppHeader(appName: appName), - const SizedBox(height: 16), - const _SectionHeader(title: 'How to Play'), - Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Padding(padding: const EdgeInsets.all(16), child: rules), - ), - const SizedBox(height: 16), - const _SectionHeader(title: 'About'), - const Card( - margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: _AppVersionTile(), - ), - const SizedBox(height: 32), - const MadeByCredit(), - const SizedBox(height: 16), - ], + return ConstrainedContentPane( + maxWidth: 720, + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + _AppHeader(appName: appName), + const SizedBox(height: 16), + const _SectionHeader(title: 'How to Play'), + Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: SelectionArea( + child: Padding(padding: const EdgeInsets.all(16), child: rules), + ), + ), + const SizedBox(height: 16), + const _SectionHeader(title: 'About'), + Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Column( + children: [ + const _AppVersionTile(), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.article_outlined), + title: const Text('Open-source licenses'), + trailing: const Icon(Icons.chevron_right), + onTap: () => showLicensePage( + context: context, + applicationName: appName, + ), + ), + ], + ), + ), + const SizedBox(height: 32), + const MadeByCredit(), + const SizedBox(height: 16), + ], + ), ); } } @@ -51,12 +71,15 @@ class _AppHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), - child: Text( - appName, - style: Theme.of(context).textTheme.headlineSmall, - textAlign: TextAlign.center, + return Semantics( + header: true, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Text( + appName, + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), ), ); } @@ -91,13 +114,16 @@ class _SectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, + return Semantics( + header: true, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), ), ), ); diff --git a/lib/features/auth/presentation/screens/login_screen.dart b/lib/features/auth/presentation/screens/login_screen.dart index 7846cdf..1ce9437 100644 --- a/lib/features/auth/presentation/screens/login_screen.dart +++ b/lib/features/auth/presentation/screens/login_screen.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:eigen_flutter/core/config/app_config.dart'; +import 'package:eigen_flutter/core/adaptive/adaptive_layout.dart'; import 'package:eigen_flutter/core/errors/error_messages.dart'; +import 'package:eigen_flutter/core/utils/deep_links.dart'; import 'package:eigen_flutter/features/auth/providers/auth_providers.dart'; +import 'package:eigen_flutter/features/auth/presentation/widgets/branded_google_button.dart'; +import 'package:url_launcher/link.dart'; /// Login screen with Google Sign-In class LoginScreen extends ConsumerWidget { @@ -11,9 +15,12 @@ class LoginScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final appName = ref.watch(appConfigProvider).branding.appName; + final appHost = ref.watch(appConfigProvider).engine.appHost; return Scaffold( body: SafeArea( - child: Center( + child: ConstrainedContentPane( + maxWidth: 480, + alignment: Alignment.center, child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( @@ -76,13 +83,38 @@ class LoginScreen extends ConsumerWidget { ), const SizedBox(height: 24), - // Terms and Privacy - Text( - 'By signing in, you agree to our Terms of Service and Privacy Policy', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, + // Terms and privacy are real links when the app host exists. + Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + 'By signing in, you agree to our ', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + _LegalLink( + label: 'Terms of Service', + uri: legalPageUrl('/terms', appHost: appHost), + ), + Text( + ' and ', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + _LegalLink( + label: 'Privacy Policy', + uri: legalPageUrl('/privacy', appHost: appHost), + ), + Text( + '.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], ), ], ), @@ -102,12 +134,9 @@ class GoogleSignInButton extends ConsumerWidget { ref.listen(authControllerProvider, (_, next) { next.whenOrNull( error: (error, _) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(humanize(error)), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(humanize(error)))); }, ); }); @@ -116,16 +145,11 @@ class GoogleSignInButton extends ConsumerWidget { authControllerProvider.select((state) => state.isLoading), ); - return AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: isLoading - ? const _SignInLoadingIndicator() - : FilledButton.icon( - onPressed: () => - ref.read(authControllerProvider.notifier).signInWithGoogle(), - icon: const Icon(Icons.login), - label: const Text('Sign in with Google'), - ), + return BrandedGoogleButton( + isLoading: isLoading, + onPressed: isLoading + ? null + : () => ref.read(authControllerProvider.notifier).signInWithGoogle(), ); } } @@ -150,22 +174,40 @@ class PlayAsGuestButton extends ConsumerWidget { } } -/// Spinner placeholder sized to match [FilledButton]'s 40 dp default height, -/// preventing layout shifts during the [AnimatedSwitcher] crossfade. -class _SignInLoadingIndicator extends StatelessWidget { - const _SignInLoadingIndicator(); +class _LegalLink extends StatelessWidget { + const _LegalLink({required this.label, required this.uri}); + + final String label; + final Uri? uri; @override Widget build(BuildContext context) { - return const SizedBox( - height: 40, - child: Center( - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator( - strokeWidth: 2.5, - strokeCap: StrokeCap.round, + final style = Theme.of(context).textTheme.bodySmall?.copyWith( + color: uri == null + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.primary, + decoration: uri == null ? null : TextDecoration.underline, + decorationColor: Theme.of(context).colorScheme.primary, + ); + if (uri == null) return Text(label, style: style); + + return Link( + uri: uri, + target: LinkTarget.blank, + builder: (context, followLink) => Semantics( + link: true, + linkUrl: uri, + label: label, + enabled: followLink != null, + onTap: followLink, + excludeSemantics: true, + child: TextButton( + onPressed: followLink, + style: TextButton.styleFrom( + minimumSize: const Size(48, 48), + padding: const EdgeInsets.symmetric(horizontal: 2), ), + child: Text(label, style: style), ), ), ); diff --git a/lib/features/auth/presentation/widgets/branded_google_button.dart b/lib/features/auth/presentation/widgets/branded_google_button.dart new file mode 100644 index 0000000..848495f --- /dev/null +++ b/lib/features/auth/presentation/widgets/branded_google_button.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +/// Google's pre-approved Sign in with Google button with Material interaction. +/// +/// The visual asset is Google's current Android/Web pill at 2x. Flutter adds a +/// 48dp hit target, keyboard focus, hover/ripple feedback, and an accessible +/// name without recolouring or redrawing the protected Google mark. +class BrandedGoogleButton extends StatelessWidget { + /// Creates the button. + const BrandedGoogleButton({ + super.key, + required this.onPressed, + this.isLoading = false, + }); + + /// Starts the provider sign-in flow. + final VoidCallback? onPressed; + + /// Replaces the brand image with progress while preserving its dimensions. + final bool isLoading; + + static const _lightAsset = 'assets/google/sign_in_with_google_light_2x.png'; + static const _darkAsset = 'assets/google/sign_in_with_google_dark_2x.png'; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final enabled = onPressed != null && !isLoading; + + return Semantics( + button: true, + enabled: enabled, + label: isLoading ? 'Signing in with Google' : 'Sign in with Google', + onTap: enabled ? onPressed : null, + excludeSemantics: true, + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: enabled ? onPressed : null, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 180, minHeight: 48), + child: Center( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: isLoading + ? const SizedBox( + key: ValueKey('google-sign-in-progress'), + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Ink.image( + key: ValueKey(isDark), + image: AssetImage( + isDark ? _darkAsset : _lightAsset, + package: 'eigen_flutter', + ), + width: 180, + height: 40, + fit: BoxFit.contain, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/game/presentation/extensions/game_ui.dart b/lib/features/game/presentation/extensions/game_ui.dart index 3bf0473..761bd5d 100644 --- a/lib/features/game/presentation/extensions/game_ui.dart +++ b/lib/features/game/presentation/extensions/game_ui.dart @@ -1,16 +1,60 @@ import 'package:eigen_api/eigen_api.dart'; +import 'package:eigen_flutter/core/theme/app_semantic_colors.dart'; import 'package:flutter/material.dart'; /// UI helpers for [GameStatus]: color and icon mappings. extension GameStatusUI on GameStatus { - /// Returns the color associated with this status from [colorScheme]. - Color color(ColorScheme colorScheme) => switch (this) { - GameStatus.waiting => colorScheme.tertiary, - GameStatus.ready => colorScheme.secondary, - GameStatus.active => colorScheme.primary, - GameStatus.finished => colorScheme.outline, - GameStatus.aborted => colorScheme.error, - GameStatus.unknownDefaultOpenApi => colorScheme.outline, + /// Returns the semantic foreground associated with this status. + /// + /// Pass the extension from the active theme so high-contrast mode is + /// preserved. The brightness-derived fallback keeps existing callers + /// brand-independent while they migrate. + Color color(ColorScheme colorScheme, {AppSemanticColors? semanticColors}) => + switch (this) { + GameStatus.waiting => _semantic(colorScheme, semanticColors).warning, + GameStatus.ready => _semantic(colorScheme, semanticColors).info, + GameStatus.active => _semantic(colorScheme, semanticColors).success, + GameStatus.finished => colorScheme.onSurfaceVariant, + GameStatus.aborted => colorScheme.error, + GameStatus.unknownDefaultOpenApi => colorScheme.onSurfaceVariant, + }; + + /// Returns the semantic container associated with this status. + Color containerColor( + ColorScheme colorScheme, { + AppSemanticColors? semanticColors, + }) => switch (this) { + GameStatus.waiting => _semantic( + colorScheme, + semanticColors, + ).warningContainer, + GameStatus.ready => _semantic(colorScheme, semanticColors).infoContainer, + GameStatus.active => _semantic( + colorScheme, + semanticColors, + ).successContainer, + GameStatus.finished || + GameStatus.unknownDefaultOpenApi => colorScheme.surfaceContainerHighest, + GameStatus.aborted => colorScheme.errorContainer, + }; + + /// Returns the paired foreground for [containerColor]. + Color onContainerColor( + ColorScheme colorScheme, { + AppSemanticColors? semanticColors, + }) => switch (this) { + GameStatus.waiting => _semantic( + colorScheme, + semanticColors, + ).onWarningContainer, + GameStatus.ready => _semantic(colorScheme, semanticColors).onInfoContainer, + GameStatus.active => _semantic( + colorScheme, + semanticColors, + ).onSuccessContainer, + GameStatus.finished || + GameStatus.unknownDefaultOpenApi => colorScheme.onSurfaceVariant, + GameStatus.aborted => colorScheme.onErrorContainer, }; /// Returns the icon associated with this status. @@ -39,14 +83,53 @@ extension OutcomeResultUI on OutcomeResultEnum? { null => Icons.cancel_outlined, }; - /// Returns the color associated with this result from [colorScheme]. - Color color(ColorScheme colorScheme) => switch (this) { - OutcomeResultEnum.win => colorScheme.primary, - OutcomeResultEnum.loss => colorScheme.error, - OutcomeResultEnum.draw => colorScheme.tertiary, - OutcomeResultEnum.eliminated => colorScheme.error, - OutcomeResultEnum.unknownDefaultOpenApi => colorScheme.outline, - null => colorScheme.outline, + /// Returns the semantic foreground associated with this result. + Color color(ColorScheme colorScheme, {AppSemanticColors? semanticColors}) => + switch (this) { + OutcomeResultEnum.win => _semantic(colorScheme, semanticColors).success, + OutcomeResultEnum.loss => colorScheme.error, + OutcomeResultEnum.draw => _semantic(colorScheme, semanticColors).info, + OutcomeResultEnum.eliminated => colorScheme.error, + OutcomeResultEnum.unknownDefaultOpenApi => colorScheme.onSurfaceVariant, + null => colorScheme.onSurfaceVariant, + }; + + /// Returns the semantic container associated with this result. + Color containerColor( + ColorScheme colorScheme, { + AppSemanticColors? semanticColors, + }) => switch (this) { + OutcomeResultEnum.win => _semantic( + colorScheme, + semanticColors, + ).successContainer, + OutcomeResultEnum.loss || + OutcomeResultEnum.eliminated => colorScheme.errorContainer, + OutcomeResultEnum.draw => _semantic( + colorScheme, + semanticColors, + ).infoContainer, + OutcomeResultEnum.unknownDefaultOpenApi || + null => colorScheme.surfaceContainerHighest, + }; + + /// Returns the paired foreground for [containerColor]. + Color onContainerColor( + ColorScheme colorScheme, { + AppSemanticColors? semanticColors, + }) => switch (this) { + OutcomeResultEnum.win => _semantic( + colorScheme, + semanticColors, + ).onSuccessContainer, + OutcomeResultEnum.loss || + OutcomeResultEnum.eliminated => colorScheme.onErrorContainer, + OutcomeResultEnum.draw => _semantic( + colorScheme, + semanticColors, + ).onInfoContainer, + OutcomeResultEnum.unknownDefaultOpenApi || + null => colorScheme.onSurfaceVariant, }; /// Returns the short display label for this result. @@ -59,3 +142,8 @@ extension OutcomeResultUI on OutcomeResultEnum? { null => 'Aborted', }; } + +AppSemanticColors _semantic( + ColorScheme colorScheme, + AppSemanticColors? semanticColors, +) => semanticColors ?? AppSemanticColors.forBrightness(colorScheme.brightness); diff --git a/lib/features/game/presentation/screens/game_screen_pre_game.dart b/lib/features/game/presentation/screens/game_screen_pre_game.dart index 96297d2..e1a498e 100644 --- a/lib/features/game/presentation/screens/game_screen_pre_game.dart +++ b/lib/features/game/presentation/screens/game_screen_pre_game.dart @@ -256,6 +256,7 @@ class _AddBotDialogState extends ConsumerState<_AddBotDialog> { final botsAsync = ref.watch(availableBotsProvider); return AlertDialog( + scrollable: true, title: const Text('Add a bot'), content: switch (botsAsync) { AsyncError(:final error) => Text(humanize(error)), diff --git a/lib/features/game/presentation/screens/history_screen.dart b/lib/features/game/presentation/screens/history_screen.dart index fb1cc86..6b08766 100644 --- a/lib/features/game/presentation/screens/history_screen.dart +++ b/lib/features/game/presentation/screens/history_screen.dart @@ -4,9 +4,12 @@ import 'package:go_router/go_router.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:intl/intl.dart'; import 'package:eigen_flutter/core/errors/error_messages.dart'; +import 'package:eigen_flutter/core/adaptive/adaptive_layout.dart'; +import 'package:eigen_flutter/core/theme/app_semantic_colors.dart'; import 'package:eigen_flutter/features/game/data/game_repository.dart'; import 'package:eigen_flutter/features/game/presentation/extensions/game_ui.dart'; +import 'package:eigen_flutter/features/rating/presentation/extensions/rating_ui.dart'; import 'package:eigen_flutter/features/game/providers/game_providers.dart'; import 'package:eigen_flutter/shared/widgets/empty_state_view.dart'; import 'package:eigen_api/eigen_api.dart'; @@ -123,49 +126,95 @@ class _HistoryScreenState extends ConsumerState { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return RefreshIndicator( - onRefresh: () async => _pagingController.refresh(), - child: PagingListener( - controller: _pagingController, - builder: (context, state, fetchNextPage) => - PagedListView( - state: state, - fetchNextPage: fetchNextPage, - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(16), - builderDelegate: PagedChildBuilderDelegate<_HistoryEntry>( - animateTransitions: true, - itemBuilder: (context, entry, _) => - _HistoryCard(key: ValueKey(entry.game.id), entry: entry), - noItemsFoundIndicatorBuilder: (_) => EmptyStateView( - icon: Icons.history, - title: 'No finished games yet', - message: 'Completed games will appear here.', - cta: 'Play your first game', - onCta: () => context.go('/lobby'), - tonalCta: true, - ), - firstPageErrorIndicatorBuilder: (_) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 48, - color: colorScheme.error, - ), - const SizedBox(height: 16), - Text(humanize(state.error ?? 'Unknown error')), - const SizedBox(height: 16), - FilledButton( - onPressed: _pagingController.refresh, - child: const Text('Retry'), + return AdaptiveLayoutBuilder( + builder: (context, constraints, windowClass) => Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: IconButton( + onPressed: _pagingController.refresh, + icon: const Icon(Icons.refresh), + tooltip: 'Refresh history', + ), + ), + ), + Expanded( + child: RefreshIndicator( + onRefresh: () async => _pagingController.refresh(), + child: PagingListener( + controller: _pagingController, + builder: (context, state, fetchNextPage) { + final builderDelegate = + PagedChildBuilderDelegate<_HistoryEntry>( + animateTransitions: true, + itemBuilder: (context, entry, _) => _HistoryCard( + key: ValueKey(entry.game.id), + entry: entry, + ), + noItemsFoundIndicatorBuilder: (_) => EmptyStateView( + icon: Icons.history, + title: 'No finished games yet', + message: 'Completed games will appear here.', + cta: 'Play your first game', + onCta: () => context.go('/lobby'), + tonalCta: true, + ), + firstPageErrorIndicatorBuilder: (_) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: colorScheme.error, + ), + const SizedBox(height: 16), + Text(humanize(state.error ?? 'Unknown error')), + const SizedBox(height: 16), + FilledButton( + onPressed: _pagingController.refresh, + child: const Text('Retry'), + ), + ], + ), + ), + ); + final useGrid = shouldUseCardGrid( + windowClass: windowClass, + textScaler: MediaQuery.textScalerOf(context), + ); + if (!useGrid) { + return ConstrainedContentPane( + maxWidth: 720, + child: PagedListView.separated( + state: state, + fetchNextPage: fetchNextPage, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + separatorBuilder: (_, _) => const SizedBox(height: 12), + builderDelegate: builderDelegate, ), - ], - ), - ), + ); + } + return PagedGridView( + state: state, + fetchNextPage: fetchNextPage, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + gridDelegate: responsiveCardGridDelegate( + availableWidth: constraints.maxWidth - 32, + maxCrossAxisExtent: 560, + mainAxisExtent: 110, + ), + builderDelegate: builderDelegate, + ); + }, ), ), + ), + ], ), ); } @@ -179,6 +228,7 @@ class _HistoryCard extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final semanticColors = AppSemanticColors.of(context); final textTheme = Theme.of(context).textTheme; final game = entry.game; final result = entry.myResult; @@ -191,11 +241,11 @@ class _HistoryCard extends StatelessWidget { final dateLabel = DateFormat.yMMMd(locale).format(date.toLocal()); return Card( - margin: const EdgeInsets.only(bottom: 12), + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, child: InkWell( onTap: () => context.pushNamed('game', pathParameters: {'gameId': game.id}), - borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(16), child: Row( @@ -204,10 +254,19 @@ class _HistoryCard extends StatelessWidget { width: 48, height: 48, decoration: BoxDecoration( - color: result.color(colorScheme).withValues(alpha: 0.12), + color: result.containerColor( + colorScheme, + semanticColors: semanticColors, + ), borderRadius: BorderRadius.circular(12), ), - child: Icon(result.icon, color: result.color(colorScheme)), + child: Icon( + result.icon, + color: result.onContainerColor( + colorScheme, + semanticColors: semanticColors, + ), + ), ), const SizedBox(width: 16), Expanded( @@ -252,19 +311,19 @@ class _RatingDelta extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final poolName = change.pool[0].toUpperCase() + change.pool.substring(1); - final Color color; + final color = change.color( + colorScheme, + semanticColors: AppSemanticColors.of(context), + ); final String triangle; final String amount; if (change.displayChange > 0) { - color = colorScheme.tertiary; triangle = '▲'; amount = '+${change.displayChange}'; } else if (change.displayChange < 0) { - color = colorScheme.error; triangle = '▼'; amount = '${change.displayChange}'; } else { - color = colorScheme.onSurfaceVariant; triangle = '–'; amount = '0'; } diff --git a/lib/features/game/presentation/screens/lobby_screen.dart b/lib/features/game/presentation/screens/lobby_screen.dart index ac0bbdd..5313c2b 100644 --- a/lib/features/game/presentation/screens/lobby_screen.dart +++ b/lib/features/game/presentation/screens/lobby_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:eigen_flutter/core/errors/error_messages.dart'; +import 'package:eigen_flutter/core/adaptive/adaptive_layout.dart'; import 'package:eigen_flutter/features/auth/providers/auth_providers.dart'; import 'package:eigen_flutter/features/game/data/game_repository.dart'; @@ -27,6 +28,8 @@ enum _LobbyMode { public, friends } class _LobbyScreenState extends ConsumerState with SingleTickerProviderStateMixin { + static const _tabNames = ['public', 'friends']; + // Guests cannot have friends. The Friends tab stays visible but disabled // (greyed, with a locked sign-in panel as its content) so guests still see // the feature exists, and app_friends_games is never called for them. @@ -34,20 +37,85 @@ class _LobbyScreenState extends ConsumerState // change that re-navigates into a fresh lobby. late final bool _isAnonymous; late final TabController _tabController; + bool _syncingFromRoute = false; + int? _directTabSelection; @override void initState() { super.initState(); _isAnonymous = ref.read(isAnonymousProvider); _tabController = TabController(length: 2, vsync: this); + _tabController.addListener(_handleControllerChange); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final routeIndex = _routeTabIndex(); + if (_tabController.index == routeIndex) return; + + // Browser Back/Forward changes the query while this stateful shell branch + // stays mounted. Update the existing controller without writing the same + // state back into the router. + _syncingFromRoute = true; + _directTabSelection = null; + _tabController.index = routeIndex; + _syncingFromRoute = false; } @override void dispose() { - _tabController.dispose(); + _tabController + ..removeListener(_handleControllerChange) + ..dispose(); super.dispose(); } + int _routeTabIndex() { + final tab = GoRouterState.of(context).uri.queryParameters['tab']; + final index = _tabNames.indexOf(tab ?? ''); + return index < 0 ? 0 : index; + } + + String _locationForTab(int index) { + final uri = GoRouterState.of(context).uri; + final query = Map.of(uri.queryParameters); + if (index == 0) { + query.remove('tab'); + } else { + query['tab'] = _tabNames[index]; + } + return uri.replace(queryParameters: query).toString(); + } + + void _handleTabTap(int index) { + if (index == _routeTabIndex()) { + _directTabSelection = null; + return; + } + // A deliberate tab click is browser-navigable history. TabBar has already + // started the controller animation before invoking onTap, so remember it + // and suppress the completion listener's replace. + _directTabSelection = index; + context.go(_locationForTab(index)); + } + + void _handleControllerChange() { + if (!mounted || _syncingFromRoute || _tabController.indexIsChanging) { + return; + } + final index = _tabController.index; + if (_directTabSelection == index) { + _directTabSelection = null; + return; + } + if (index == _routeTabIndex()) return; + + // Swiping the TabBarView (or another controller-driven animation) should + // expose its state in the URL without adding a second history entry. + Router.neglect(context, () => context.go(_locationForTab(index))); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -57,6 +125,7 @@ class _LobbyScreenState extends ConsumerState children: [ TabBar( controller: _tabController, + onTap: _handleTabTap, tabs: [ const Tab(icon: Icon(Icons.public), text: 'Public'), Tab( @@ -179,60 +248,105 @@ class _LobbyTabContentState extends ConsumerState<_LobbyTabContent> super.build(context); final colorScheme = Theme.of(context).colorScheme; - return RefreshIndicator( - onRefresh: () async => _pagingController.refresh(), - child: PagingListener( - controller: _pagingController, - builder: (context, state, fetchNextPage) => - PagedListView( - state: state, - fetchNextPage: fetchNextPage, - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(16), - builderDelegate: PagedChildBuilderDelegate( - animateTransitions: true, - itemBuilder: (context, item, index) => _GameCard( - key: ValueKey(item.id), - game: item, - onCancelled: _pagingController.refresh, - onJoined: _pagingController.refresh, - ), - noItemsFoundIndicatorBuilder: (_) => EmptyStateView( - icon: Icons.sports_esports_outlined, - title: 'No open games right now', - message: switch (widget.mode) { - _LobbyMode.friends => - 'None of your friends have an open game.', - _LobbyMode.public => 'Be the first to start one.', - }, - cta: 'Create Game', - onCta: () => showDialog( - context: context, - useSafeArea: true, - builder: (_) => const NewGameDialog(), - ), - ), - firstPageErrorIndicatorBuilder: (_) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 48, - color: colorScheme.error, - ), - const SizedBox(height: 16), - Text(humanize(state.error ?? 'Unknown error')), - const SizedBox(height: 16), - FilledButton( - onPressed: _pagingController.refresh, - child: const Text('Retry'), + return AdaptiveLayoutBuilder( + builder: (context, constraints, windowClass) => Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: IconButton( + onPressed: _pagingController.refresh, + icon: const Icon(Icons.refresh), + tooltip: 'Refresh games', + ), + ), + ), + Expanded( + child: RefreshIndicator( + onRefresh: () async => _pagingController.refresh(), + child: PagingListener( + controller: _pagingController, + builder: (context, state, fetchNextPage) { + final builderDelegate = + PagedChildBuilderDelegate( + animateTransitions: true, + itemBuilder: (context, item, index) => _GameCard( + key: ValueKey(item.id), + game: item, + onCancelled: _pagingController.refresh, + onJoined: _pagingController.refresh, + ), + noItemsFoundIndicatorBuilder: (_) => EmptyStateView( + icon: Icons.sports_esports_outlined, + title: 'No open games right now', + message: switch (widget.mode) { + _LobbyMode.friends => + 'None of your friends have an open game.', + _LobbyMode.public => 'Be the first to start one.', + }, + cta: 'Create Game', + onCta: () => showDialog( + context: context, + useSafeArea: true, + builder: (_) => const NewGameDialog(), + ), + ), + firstPageErrorIndicatorBuilder: (_) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: colorScheme.error, + ), + const SizedBox(height: 16), + Text(humanize(state.error ?? 'Unknown error')), + const SizedBox(height: 16), + FilledButton( + onPressed: _pagingController.refresh, + child: const Text('Retry'), + ), + ], + ), + ), + ); + final useGrid = shouldUseCardGrid( + windowClass: windowClass, + textScaler: MediaQuery.textScalerOf(context), + ); + if (!useGrid) { + return ConstrainedContentPane( + maxWidth: 720, + child: PagedListView.separated( + state: state, + fetchNextPage: fetchNextPage, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + separatorBuilder: (_, _) => const SizedBox(height: 12), + builderDelegate: builderDelegate, ), - ], - ), - ), + ); + } + return PagedGridView( + state: state, + fetchNextPage: fetchNextPage, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + gridDelegate: responsiveCardGridDelegate( + availableWidth: constraints.maxWidth - 32, + maxCrossAxisExtent: 580, + mainAxisExtent: 140, + twoColumnWidth: 1000, + ), + builderDelegate: builderDelegate, + ); + }, ), ), + ), + ], ), ); } @@ -295,82 +409,121 @@ class _GameCardState extends ConsumerState<_GameCard> { } } - return Card( - margin: const EdgeInsets.only(bottom: 12), - child: ListTile( - onTap: canNavigate - ? () => context.pushNamed( - 'game', - pathParameters: {'gameId': widget.game.id}, - ) - : null, - leading: avatars.isNotEmpty - ? OverlappingAvatars(players: avatars, radius: 18) - : CircleAvatar( - backgroundColor: isOwner - ? colorScheme.secondaryContainer - : colorScheme.primaryContainer, - child: Icon( - gameTimingIcon(widget.game), - color: isOwner - ? colorScheme.onSecondaryContainer - : colorScheme.onPrimaryContainer, - ), - ), - title: Text( - isOwner ? 'Your Room' : 'Game #${widget.game.id.substring(0, 8)}', - style: textTheme.titleMedium, - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${gameTimingLabel(widget.game)} • ${widget.game.access.name}', - style: textTheme.bodySmall, - ), - const SizedBox(height: 4), - _PlayerSlots( - playerCount: playerCount, - minPlayers: widget.game.minPlayers, - maxPlayers: widget.game.maxPlayers, - waitLabel: formatWaitDuration(widget.game.createdAt), - colorScheme: colorScheme, - textTheme: textTheme, + final openGame = canNavigate + ? () => context.pushNamed( + 'game', + pathParameters: {'gameId': widget.game.id}, + ) + : null; + final leading = avatars.isNotEmpty + ? OverlappingAvatars(players: avatars, radius: 18) + : CircleAvatar( + backgroundColor: isOwner + ? colorScheme.secondaryContainer + : colorScheme.primaryContainer, + child: Icon( + gameTimingIcon(widget.game), + color: isOwner + ? colorScheme.onSecondaryContainer + : colorScheme.onPrimaryContainer, ), - ], + ); + final title = Text( + isOwner ? 'Your Room' : 'Game #${widget.game.id.substring(0, 8)}', + style: textTheme.titleMedium, + ); + final details = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${gameTimingLabel(widget.game)} • ${widget.game.access.name}', + style: textTheme.bodySmall, + ), + const SizedBox(height: 4), + _PlayerSlots( + playerCount: playerCount, + minPlayers: widget.game.minPlayers, + maxPlayers: widget.game.maxPlayers, + waitLabel: formatWaitDuration(widget.game.createdAt), + colorScheme: colorScheme, + textTheme: textTheme, ), - isThreeLine: true, - trailing: _isLoading - ? const SizedBox( - width: 16, - height: 16, + ], + ); + final action = _isLoading + ? const SizedBox( + width: 40, + height: 40, + child: Center( + child: SizedBox.square( + dimension: 16, child: CircularProgressIndicator(strokeWidth: 2), - ) - : isOwner - ? OutlinedButton( - onPressed: _cancelGame, - style: OutlinedButton.styleFrom( - foregroundColor: colorScheme.error, - side: BorderSide(color: colorScheme.error), - ), - child: const Text('Cancel'), - ) - : isParticipant - ? OutlinedButton( - onPressed: () => context.pushNamed( - 'game', - pathParameters: {'gameId': widget.game.id}, - ), - child: const Text('View'), - ) - : !supported - ? const FilledButton(onPressed: null, child: Text('Update to join')) - : ratedBlockedForGuest - ? const FilledButton( - onPressed: null, - child: Text('Sign up to play rated'), - ) - : FilledButton(onPressed: _joinGame, child: const Text('Join')), + ), + ), + ) + : isOwner + ? OutlinedButton( + onPressed: _cancelGame, + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide(color: colorScheme.error), + ), + child: const Text('Cancel'), + ) + : isParticipant + ? OutlinedButton(onPressed: openGame, child: const Text('View')) + : !supported + ? const FilledButton(onPressed: null, child: Text('Update to join')) + : ratedBlockedForGuest + ? const FilledButton( + onPressed: null, + child: Text('Sign up to play rated'), + ) + : FilledButton(onPressed: _joinGame, child: const Text('Join')); + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= 480) { + return ListTile( + onTap: openGame, + leading: leading, + title: title, + subtitle: details, + isThreeLine: true, + trailing: action, + ); + } + return InkWell( + onTap: openGame, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + leading, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [title, const SizedBox(height: 4), details], + ), + ), + ], + ), + const SizedBox(height: 12), + Align(alignment: Alignment.centerRight, child: action), + ], + ), + ), + ); + }, ), ); } @@ -450,21 +603,28 @@ class _PlayerSlots extends StatelessWidget { final capacitySuffix = !isReady && maxPlayers > minPlayers ? ' • $maxPlayers max' : ''; - return Row( + return Wrap( + spacing: 4, + runSpacing: 2, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - for (int i = 0; i < minPlayers; i++) - Container( - width: 8, - height: 8, - margin: const EdgeInsets.only(right: 4), - decoration: BoxDecoration( - color: i < playerCount - ? colorScheme.primary - : colorScheme.outlineVariant, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < minPlayers; i++) + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(right: 4), + decoration: BoxDecoration( + color: i < playerCount + ? colorScheme.primary + : colorScheme.outlineVariant, + shape: BoxShape.circle, + ), + ), + ], + ), Text( '$fraction$capacitySuffix • $waitLabel', style: textTheme.bodySmall?.copyWith( diff --git a/lib/features/game/presentation/widgets/budget_clock.dart b/lib/features/game/presentation/widgets/budget_clock.dart index 5386512..c4851b8 100644 --- a/lib/features/game/presentation/widgets/budget_clock.dart +++ b/lib/features/game/presentation/widgets/budget_clock.dart @@ -4,6 +4,7 @@ import 'package:eigen_flutter/core/connectivity/connectivity_provider.dart'; import 'package:eigen_flutter/core/game/timing_constants.dart'; import 'package:eigen_flutter/features/game/presentation/widgets/timer_builders.dart'; import 'package:eigen_flutter/core/api/engine_api_providers.dart'; +import 'package:eigen_flutter/core/theme/app_semantic_colors.dart'; /// Shows all players' remaining time banks side by side for budget /// (accumulated clock) games. @@ -112,17 +113,15 @@ class _ClockCell extends StatelessWidget { bool get _isUrgent => remainingMs < 60000; - Color get _timeColor { - if (_isUrgent) return colorScheme.error; - if (isActive) return colorScheme.primary; + Color _timeColor(AppSemanticColors semanticColors) { + if (_isUrgent && isActive) return colorScheme.onErrorContainer; + if (isActive) return semanticColors.onInfoContainer; return colorScheme.onSurfaceVariant; } - Color get _bgColor { - if (_isUrgent && isActive) { - return colorScheme.errorContainer.withValues(alpha: 0.3); - } - if (isActive) return colorScheme.primaryContainer.withValues(alpha: 0.3); + Color _backgroundColor(AppSemanticColors semanticColors) { + if (_isUrgent && isActive) return colorScheme.errorContainer; + if (isActive) return semanticColors.infoContainer; return colorScheme.surfaceContainerHighest; } @@ -136,18 +135,17 @@ class _ClockCell extends StatelessWidget { @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; + final semanticColors = AppSemanticColors.of(context); return AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: _bgColor, + color: _backgroundColor(semanticColors), borderRadius: BorderRadius.circular(8), border: isActive ? Border.all( - color: _isUrgent - ? colorScheme.error.withValues(alpha: 0.5) - : colorScheme.primary.withValues(alpha: 0.5), + color: _isUrgent ? colorScheme.error : semanticColors.info, ) : null, ), @@ -158,7 +156,9 @@ class _ClockCell extends StatelessWidget { isSubmitZone ? 'Submit!' : label, style: textTheme.labelSmall?.copyWith( color: isActive - ? (_isUrgent ? colorScheme.error : colorScheme.primary) + ? (_isUrgent + ? colorScheme.onErrorContainer + : semanticColors.onInfoContainer) : colorScheme.onSurfaceVariant, fontWeight: isSubmitZone ? FontWeight.bold : null, ), @@ -166,7 +166,9 @@ class _ClockCell extends StatelessWidget { const SizedBox(height: 4), Text( _formatted, - style: textTheme.titleMedium?.copyWith(color: _timeColor), + style: textTheme.titleMedium?.copyWith( + color: _timeColor(semanticColors), + ), ), ], ), diff --git a/lib/features/game/presentation/widgets/new_game_dialog.dart b/lib/features/game/presentation/widgets/new_game_dialog.dart index 392ef6f..5def11a 100644 --- a/lib/features/game/presentation/widgets/new_game_dialog.dart +++ b/lib/features/game/presentation/widgets/new_game_dialog.dart @@ -9,6 +9,7 @@ import 'package:eigen_flutter/features/auth/providers/auth_providers.dart'; import 'package:eigen_flutter/features/game/presentation/widgets/timing_selector.dart'; import 'package:eigen_flutter/features/game/providers/game_providers.dart'; +import 'package:eigen_flutter/shared/widgets/adaptive_single_choice.dart'; import 'package:eigen_api/eigen_api.dart'; /// Dialog for creating a new game. @@ -93,119 +94,101 @@ class _NewGameDialogState extends ConsumerState { final ratingEligible = !isAnonymous && pool != null; final effectiveRated = ratingEligible && _rated; + final content = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Access ──────────────────────────────────────────────── + Text('Access', style: textTheme.labelLarge), + const SizedBox(height: 8), + AdaptiveSingleChoice( + choices: [ + const AdaptiveChoice(value: GameAccess.public, label: 'Public'), + const AdaptiveChoice(value: GameAccess.private, label: 'Private'), + // Shown but disabled for guests: they cannot have friends, so + // a friends-access game would be unjoinable (server enforces). + AdaptiveChoice( + value: GameAccess.friends, + label: 'Friends', + enabled: !isAnonymous, + ), + ], + value: _access, + label: 'Access', + onChanged: (selection) => setState(() => _access = selection), + ), + const SizedBox(height: 16), + + // ── Timing ──────────────────────────────────────────────── + TimingSelector( + configs: _spec.timingConfigs, + enabled: !_isLoading, + onChanged: (timing) => setState(() => _timing = timing), + ), + + // ── Game-specific config ────────────────────────────────── + if (_creationConfigWidget != null) ...[ + const SizedBox(height: 16), + _creationConfigWidget!, + ], + + // ── Rated toggle ────────────────────────────────────────── + // Shown only when this config is rating-eligible (and the user is + // not a guest), decided locally by GameRules.ratingPool. An + // ineligible config is casual-only, so there is nothing to toggle. + if (ratingEligible) ...[ + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Rated'), + value: _rated, + onChanged: (v) => setState(() => _rated = v), + ), + ], + + // Local Rated/Casual badge derived from GameRules.ratingPool (the + // server recomputes the authoritative pool at creation). + const SizedBox(height: 4), + Wrap( + spacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Icon( + effectiveRated + ? Icons.emoji_events_outlined + : Icons.sports_esports_outlined, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + Text( + effectiveRated ? 'Rated · $pool' : 'Casual', + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ); + return Dialog( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), + constraints: const BoxConstraints(maxWidth: 480), child: Padding( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('New Game', style: textTheme.headlineSmall), - const SizedBox(height: 24), - - // ── Access ──────────────────────────────────────────────── - Text('Access', style: textTheme.labelLarge), - const SizedBox(height: 8), - SegmentedButton( - showSelectedIcon: false, - segments: [ - const ButtonSegment( - value: GameAccess.public, - label: Text('Public'), - ), - const ButtonSegment( - value: GameAccess.private, - label: Text('Private'), - ), - // Shown but disabled for guests: they cannot have friends, so - // a friends-access game would be unjoinable (server enforces). - ButtonSegment( - value: GameAccess.friends, - label: const Text('Friends'), - enabled: !isAnonymous, - ), - ], - selected: {_access}, - onSelectionChanged: (s) => setState(() => _access = s.first), - ), const SizedBox(height: 16), - - // ── Timing ──────────────────────────────────────────────── - TimingSelector( - configs: _spec.timingConfigs, - enabled: !_isLoading, - onChanged: (timing) => setState(() => _timing = timing), - ), - - // ── Game-specific config ────────────────────────────────── - if (_creationConfigWidget != null) ...[ - const SizedBox(height: 16), - _creationConfigWidget!, - ], - - // ── Rated toggle ────────────────────────────────────────── - // Shown only when this config is rating-eligible (and the user is - // not a guest), decided locally by GameRules.ratingPool. An - // ineligible config is casual-only, so there is nothing to toggle. - if (ratingEligible) ...[ - const SizedBox(height: 8), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Rated'), - value: _rated, - onChanged: (v) => setState(() => _rated = v), - ), - ], - - // Local Rated/Casual badge derived from GameRules.ratingPool (the - // server recomputes the authoritative pool at creation). - const SizedBox(height: 4), - Row( - children: [ - Icon( - effectiveRated - ? Icons.emoji_events_outlined - : Icons.sports_esports_outlined, - size: 16, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 6), - Text( - effectiveRated ? 'Rated · $pool' : 'Casual', - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - - const SizedBox(height: 24), - - // ── Actions ─────────────────────────────────────────────── - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: _isLoading ? null : () => Navigator.pop(context), - child: const Text('Cancel'), - ), - const SizedBox(width: 8), - FilledButton( - onPressed: _isLoading ? null : _createGame, - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colorScheme.onPrimary, - ), - ) - : const Text('Create'), - ), - ], + Flexible(child: SingleChildScrollView(child: content)), + const SizedBox(height: 16), + Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: _actions(context), ), ], ), @@ -214,6 +197,23 @@ class _NewGameDialogState extends ConsumerState { ); } + List _actions(BuildContext context) => [ + TextButton( + onPressed: _isLoading ? null : () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _isLoading ? null : _createGame, + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create'), + ), + ]; + Future _createGame() async { setState(() => _isLoading = true); try { diff --git a/lib/features/game/presentation/widgets/play_vs_bot_dialog.dart b/lib/features/game/presentation/widgets/play_vs_bot_dialog.dart index f25f2b7..76a79d9 100644 --- a/lib/features/game/presentation/widgets/play_vs_bot_dialog.dart +++ b/lib/features/game/presentation/widgets/play_vs_bot_dialog.dart @@ -7,6 +7,7 @@ import 'package:eigen_flutter/core/game/game_module.dart'; import 'package:eigen_api/eigen_api.dart'; import 'package:eigen_flutter/features/game/presentation/widgets/timing_selector.dart'; import 'package:eigen_flutter/features/game/providers/game_providers.dart'; +import 'package:eigen_flutter/shared/widgets/adaptive_single_choice.dart'; /// Solo-game picker (the "New Solo Game" FAB): choose an opponent for each bot /// seat and start a solo game (you + bots) immediately, with no waiting room. @@ -159,64 +160,67 @@ class _PlayVsBotDialogState extends ConsumerState { return AlertDialog( title: const Text('New Solo Game'), - content: switch (botsAsync) { - AsyncError(:final error) => Text(humanize(error)), - AsyncData() => SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (_creationConfigWidget != null) ...[ - _creationConfigWidget!, - const SizedBox(height: 16), - ], - if (_minPlayers < _maxPlayers) ...[ - _PlayersSelector( - min: _minPlayers, - max: _maxPlayers, - value: _totalPlayers, + content: SizedBox( + width: 480, + child: switch (botsAsync) { + AsyncError(:final error) => Text(humanize(error)), + AsyncData() => SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_creationConfigWidget != null) ...[ + _creationConfigWidget!, + const SizedBox(height: 16), + ], + if (_minPlayers < _maxPlayers) ...[ + _PlayersSelector( + min: _minPlayers, + max: _maxPlayers, + value: _totalPlayers, + enabled: !_creating, + onChanged: (n) => setState(() => _totalPlayers = n), + ), + const SizedBox(height: 16), + ], + TimingSelector( + configs: module.creationSpec.timingConfigs, enabled: !_creating, - onChanged: (n) => setState(() => _totalPlayers = n), + initialKey: _initialTimingKey, + onChanged: (timing) => setState(() => _timing = timing), ), const SizedBox(height: 16), + // Opponent selectors, filtered locally by module.botSeatable, so + // they update instantly as the config changes (no refetch). + if (usable.isEmpty) + const Text('No AI opponents are available for this game yet.') + else + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < opponents; i++) + _OpponentRow( + label: opponents == 1 + ? 'Opponent' + : 'Opponent ${i + 1}', + value: _seatBot(i, usable), + bots: usable, + enabled: !_creating, + onChanged: (id) => + setState(() => _seatOverrides[i] = id), + ), + ], + ), ], - TimingSelector( - configs: module.creationSpec.timingConfigs, - enabled: !_creating, - initialKey: _initialTimingKey, - onChanged: (timing) => setState(() => _timing = timing), - ), - const SizedBox(height: 16), - // Opponent selectors, filtered locally by module.botSeatable, so - // they update instantly as the config changes (no refetch). - if (usable.isEmpty) - const Text('No AI opponents are available for this game yet.') - else - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var i = 0; i < opponents; i++) - _OpponentRow( - label: opponents == 1 - ? 'Opponent' - : 'Opponent ${i + 1}', - value: _seatBot(i, usable), - bots: usable, - enabled: !_creating, - onChanged: (id) => - setState(() => _seatOverrides[i] = id), - ), - ], - ), - ], + ), ), - ), - _ => const SizedBox( - height: 80, - child: Center(child: CircularProgressIndicator()), - ), - }, + _ => const SizedBox( + height: 80, + child: Center(child: CircularProgressIndicator()), + ), + }, + ), actions: [ TextButton( onPressed: _creating ? null : () => Navigator.pop(context), @@ -301,15 +305,16 @@ class _PlayersSelector extends StatelessWidget { children: [ Text('Players', style: Theme.of(context).textTheme.bodyMedium), const SizedBox(height: 8), - SegmentedButton( - segments: [ + AdaptiveSingleChoice( + choices: [ for (var n = min; n <= max; n++) - ButtonSegment(value: n, label: Text('$n')), + AdaptiveChoice(value: n, label: '$n'), ], - selected: {value}, - onSelectionChanged: enabled - ? (selection) => onChanged(selection.first) - : null, + value: value, + enabled: enabled, + label: 'Players', + minimumSegmentWidth: 64, + onChanged: onChanged, ), ], ); @@ -336,23 +341,19 @@ class _OpponentRow extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - Expanded(child: Text(label)), - const SizedBox(width: 12), - DropdownButton( - value: value, - items: [ - for (final b in bots) - DropdownMenuItem(value: b.id, child: Text(b.displayName)), - ], - onChanged: enabled - ? (id) { - if (id != null) onChanged(id); - } - : null, - ), + child: DropdownMenu( + key: ValueKey((label, value)), + initialSelection: value, + enabled: enabled, + expandedInsets: EdgeInsets.zero, + label: Text(label), + dropdownMenuEntries: [ + for (final bot in bots) + DropdownMenuEntry(value: bot.id, label: bot.displayName), ], + onSelected: (id) { + if (id != null) onChanged(id); + }, ), ); } diff --git a/lib/features/game/presentation/widgets/timing_selector.dart b/lib/features/game/presentation/widgets/timing_selector.dart index 9f206b6..cda2405 100644 --- a/lib/features/game/presentation/widgets/timing_selector.dart +++ b/lib/features/game/presentation/widgets/timing_selector.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:eigen_flutter/core/game/game_creation_spec.dart'; +import 'package:eigen_flutter/shared/widgets/adaptive_single_choice.dart'; /// Timing values resolved from a [TimingSelector], ready for the create RPCs. typedef ResolvedTiming = ({ @@ -97,19 +98,19 @@ class _TimingSelectorState extends State { Text('Timing', style: textTheme.labelLarge), const SizedBox(height: 8), if (configs.length > 1) ...[ - SegmentedButton( - showSelectedIcon: false, - segments: configs.keys - .map((k) => ButtonSegment(value: k, label: Text(k))) - .toList(), - selected: {_key}, - onSelectionChanged: widget.enabled - ? (s) => setState(() { - _key = s.first; - _applyDefaults(configs[_key]!); - _emit(); - }) - : null, + AdaptiveSingleChoice( + choices: [ + for (final key in configs.keys) + AdaptiveChoice(value: key, label: key), + ], + value: _key, + enabled: widget.enabled, + label: 'Timing mode', + onChanged: (selection) => setState(() { + _key = selection; + _applyDefaults(configs[_key]!); + _emit(); + }), ), const SizedBox(height: 12), ], diff --git a/lib/features/home/presentation/screens/home_screen.dart b/lib/features/home/presentation/screens/home_screen.dart index 632616d..ec983fa 100644 --- a/lib/features/home/presentation/screens/home_screen.dart +++ b/lib/features/home/presentation/screens/home_screen.dart @@ -6,6 +6,8 @@ import 'package:go_router/go_router.dart'; import 'package:shimmer/shimmer.dart'; import 'package:eigen_flutter/features/game/presentation/extensions/game_ui.dart'; +import 'package:eigen_flutter/core/adaptive/adaptive_layout.dart'; +import 'package:eigen_flutter/core/theme/app_semantic_colors.dart'; import 'package:eigen_flutter/features/auth/providers/auth_providers.dart'; import 'package:eigen_flutter/features/game/presentation/widgets/turn_countdown.dart'; import 'package:eigen_flutter/features/game/providers/game_providers.dart'; @@ -44,52 +46,55 @@ class _HomeScreenState extends ConsumerState { } }); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _WelcomeHeader(), - const SizedBox(height: 16), - Expanded( - child: RefreshIndicator( - onRefresh: _onRefresh, - child: activeGamesAsync.when( - skipLoadingOnReload: true, - data: (entries) => entries.isEmpty - ? _EmptyState( - onBrowseLobby: () => context.go('/lobby'), - onJoinViaCode: () => _showJoinCodeDialog(context), - ) - : _GamesList( - entries: entries, - lastRefreshed: _lastRefreshed, - onRefresh: _onRefresh, - onBrowseLobby: () => context.go('/lobby'), - onJoinViaCode: () => _showJoinCodeDialog(context), - ), - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 48, - color: colorScheme.error, - ), - const SizedBox(height: 16), - const Text('Error loading games'), - const SizedBox(height: 8), - FilledButton( - onPressed: () => ref.invalidate(activeGamesProvider), - child: const Text('Retry'), - ), - ], + return ConstrainedContentPane( + maxWidth: 1200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _WelcomeHeader(), + const SizedBox(height: 16), + Expanded( + child: RefreshIndicator( + onRefresh: _onRefresh, + child: activeGamesAsync.when( + skipLoadingOnReload: true, + data: (entries) => entries.isEmpty + ? _EmptyState( + onBrowseLobby: () => context.go('/lobby'), + onJoinViaCode: () => _showJoinCodeDialog(context), + ) + : _GamesList( + entries: entries, + lastRefreshed: _lastRefreshed, + onRefresh: _onRefresh, + onBrowseLobby: () => context.go('/lobby'), + onJoinViaCode: () => _showJoinCodeDialog(context), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: colorScheme.error, + ), + const SizedBox(height: 16), + const Text('Error loading games'), + const SizedBox(height: 8), + FilledButton( + onPressed: () => ref.invalidate(activeGamesProvider), + child: const Text('Retry'), + ), + ], + ), ), ), ), ), - ), - ], + ], + ), ); } @@ -113,6 +118,7 @@ class _JoinCodeDialog extends StatefulWidget { } class _JoinCodeDialogState extends State<_JoinCodeDialog> { + final _formKey = GlobalKey(); final _controller = TextEditingController(); @override @@ -122,8 +128,8 @@ class _JoinCodeDialogState extends State<_JoinCodeDialog> { } void _join() { + if (!_formKey.currentState!.validate()) return; final code = _controller.text.trim().toUpperCase(); - if (code.length != 6) return; // Capture the router before popping; this State's context is being // removed from the tree once the dialog closes. final router = GoRouter.of(context); @@ -135,14 +141,26 @@ class _JoinCodeDialogState extends State<_JoinCodeDialog> { Widget build(BuildContext context) { return AlertDialog( title: const Text('Join Private Game'), - content: TextField( - controller: _controller, - decoration: const InputDecoration( - hintText: 'Enter 6-character code', - border: OutlineInputBorder(), + content: Form( + key: _formKey, + child: TextFormField( + controller: _controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Invite code', + hintText: 'Enter 6-character code', + border: OutlineInputBorder(), + ), + textCapitalization: TextCapitalization.characters, + textInputAction: TextInputAction.done, + maxLength: 6, + autocorrect: false, + enableSuggestions: false, + validator: (value) => value?.trim().length == 6 + ? null + : 'Enter the complete 6-character code', + onFieldSubmitted: (_) => _join(), ), - textCapitalization: TextCapitalization.characters, - maxLength: 6, ), actions: [ TextButton( @@ -292,15 +310,16 @@ class _EmptyState extends StatelessWidget { textAlign: TextAlign.center, ), const SizedBox(height: 32), - Row( - mainAxisAlignment: MainAxisAlignment.center, + Wrap( + alignment: WrapAlignment.center, + spacing: 16, + runSpacing: 12, children: [ OutlinedButton.icon( onPressed: onBrowseLobby, icon: const Icon(Icons.search), label: const Text('Browse Lobby'), ), - const SizedBox(width: 16), FilledButton.icon( onPressed: onJoinViaCode, icon: const Icon(Icons.login), @@ -340,55 +359,90 @@ class _GamesList extends StatelessWidget { Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Active Games', - style: textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - _UpdatedAgoLabel(refreshedAt: lastRefreshed), - ], - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.refresh), - onPressed: onRefresh, - tooltip: 'Refresh', - iconSize: 20, - ), - IconButton( - icon: const Icon(Icons.login), - onPressed: onJoinViaCode, - tooltip: 'Join via Code', - iconSize: 20, - ), - TextButton.icon( - onPressed: onBrowseLobby, - icon: const Icon(Icons.search, size: 18), - label: const Text('Lobby'), + return AdaptiveLayoutBuilder( + builder: (context, constraints, windowClass) { + final useGrid = shouldUseCardGrid( + windowClass: windowClass, + textScaler: MediaQuery.textScalerOf(context), + ); + final heading = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Active Games', + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, ), - ], - ), - ), - const SizedBox(height: 8), - Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: entries.length, - itemBuilder: (context, index) => _GameCard(entry: entries[index]), - ), - ), - ], + ), + _UpdatedAgoLabel(refreshedAt: lastRefreshed), + ], + ); + final actions = Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: onRefresh, + tooltip: 'Refresh', + iconSize: 20, + ), + IconButton( + icon: const Icon(Icons.login), + onPressed: onJoinViaCode, + tooltip: 'Join via Code', + iconSize: 20, + ), + TextButton.icon( + onPressed: onBrowseLobby, + icon: const Icon(Icons.search, size: 18), + label: const Text('Lobby'), + ), + ], + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: windowClass.isCompact + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + heading, + Align(alignment: Alignment.centerRight, child: actions), + ], + ) + : Row(children: [heading, const Spacer(), actions]), + ), + const SizedBox(height: 8), + Expanded( + child: useGrid + ? GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + gridDelegate: responsiveCardGridDelegate( + availableWidth: constraints.maxWidth - 32, + maxCrossAxisExtent: 560, + mainAxisExtent: 112, + ), + itemCount: entries.length, + itemBuilder: (context, index) => + _GameCard(entry: entries[index]), + ) + : ConstrainedContentPane( + maxWidth: 720, + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + itemCount: entries.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, index) => + _GameCard(entry: entries[index]), + ), + ), + ), + ], + ); + }, ); } } @@ -402,6 +456,7 @@ class _GameCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; + final semanticColors = AppSemanticColors.of(context); final textTheme = Theme.of(context).textTheme; final game = entry; @@ -436,11 +491,11 @@ class _GameCard extends ConsumerWidget { } return Card( - margin: const EdgeInsets.only(bottom: 12), + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, child: InkWell( onTap: () => context.pushNamed('game', pathParameters: {'gameId': game.id}), - borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(16), child: Row( @@ -456,14 +511,18 @@ class _GameCard extends ConsumerWidget { height: 48, margin: const EdgeInsets.only(right: 16), decoration: BoxDecoration( - color: game.status - .color(colorScheme) - .withValues(alpha: 0.1), + color: game.status.containerColor( + colorScheme, + semanticColors: semanticColors, + ), borderRadius: BorderRadius.circular(12), ), child: Icon( game.status.icon, - color: game.status.color(colorScheme), + color: game.status.onContainerColor( + colorScheme, + semanticColors: semanticColors, + ), ), ), Expanded( @@ -483,7 +542,10 @@ class _GameCard extends ConsumerWidget { ), if (isMyTurn != null) ...[ const SizedBox(height: 2), - Row( + Wrap( + spacing: 4, + runSpacing: 2, + crossAxisAlignment: WrapCrossAlignment.center, children: [ Text( isMyTurn ? '• Your turn' : '• Waiting', @@ -495,7 +557,6 @@ class _GameCard extends ConsumerWidget { ), ), if (isMyTurn && game.turnDeadline != null) ...[ - const SizedBox(width: 4), TurnCountdown(deadline: game.turnDeadline!), ], ], diff --git a/lib/features/profile/presentation/screens/profile_screen.dart b/lib/features/profile/presentation/screens/profile_screen.dart index 2106709..b0d247b 100644 --- a/lib/features/profile/presentation/screens/profile_screen.dart +++ b/lib/features/profile/presentation/screens/profile_screen.dart @@ -85,10 +85,24 @@ class _ProfileScreenState extends ConsumerState { } void _showEditSheet(BuildContext context, Profile profile) { + if (MediaQuery.sizeOf(context).width >= 840) { + showDialog( + context: context, + builder: (_) => Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: _EditProfileSheet(profile: profile), + ), + ), + ); + return; + } + showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, + showDragHandle: true, builder: (_) => _EditProfileSheet(profile: profile), ); } @@ -114,21 +128,15 @@ class _ProfileScreenState extends ConsumerState { await ref.read(currentUserProfileProvider.notifier).uploadAvatar(bytes); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Profile photo updated!'), - backgroundColor: Theme.of(context).colorScheme.primary, - ), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Profile photo updated!'))); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to update photo: $e'), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to update photo: $e'))); } } } @@ -159,6 +167,7 @@ class _ProfileScreenState extends ConsumerState { Future _showImageSourceSheet() { return showModalBottomSheet( context: context, + showDragHandle: true, builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, @@ -219,16 +228,22 @@ class _HeroBanner extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - GestureDetector( - onTap: onAvatarTap, - child: Stack( - clipBehavior: Clip.none, - children: [ - _AvatarDisplay(avatarUrl: avatarUrl, radius: 60), - if (onAvatarTap != null) - Positioned( - bottom: 2, - right: 2, + Stack( + clipBehavior: Clip.none, + children: [ + _AvatarDisplay( + avatarUrl: avatarUrl, + radius: 60, + onTap: onAvatarTap, + semanticLabel: onAvatarTap == null + ? null + : 'Change profile photo', + ), + if (onAvatarTap != null) + Positioned( + bottom: 2, + right: 2, + child: IgnorePointer( child: CircleAvatar( radius: 12, backgroundColor: colorScheme.primary, @@ -239,8 +254,8 @@ class _HeroBanner extends StatelessWidget { ), ), ), - ], - ), + ), + ], ), if (username != null) ...[ const SizedBox(height: 8), @@ -262,10 +277,17 @@ class _HeroBanner extends StatelessWidget { // ── Avatar display ──────────────────────────────────────────────────────────── class _AvatarDisplay extends StatelessWidget { - const _AvatarDisplay({required this.avatarUrl, required this.radius}); + const _AvatarDisplay({ + required this.avatarUrl, + required this.radius, + this.onTap, + this.semanticLabel, + }); final String? avatarUrl; final double radius; + final VoidCallback? onTap; + final String? semanticLabel; @override Widget build(BuildContext context) { @@ -273,7 +295,12 @@ class _AvatarDisplay extends StatelessWidget { // Worker's relative `/avatars/{uid}` path before CachedNetworkImage sees // it. The old profile-only widget skipped that step and worked only when a // bucket publicBaseUrl happened to make avatarUrl absolute. - return PlayerAvatar(avatarUrl: avatarUrl, radius: radius); + return PlayerAvatar( + avatarUrl: avatarUrl, + radius: radius, + onTap: onTap, + semanticLabel: semanticLabel, + ); } } @@ -338,21 +365,26 @@ class _EditProfileSheetState extends ConsumerState<_EditProfileSheet> { Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - return Padding( - padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const _DragHandle(), - Padding( - padding: const EdgeInsets.fromLTRB(24, 4, 24, 28), - child: Text('Edit Profile', style: textTheme.titleLarge), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( + return SafeArea( + top: false, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + 24, + 24, + 24, + 24 + MediaQuery.viewInsetsOf(context).bottom, + ), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 28), + child: Text('Edit Profile', style: textTheme.titleLarge), + ), + Column( children: [ TextFormField( key: ValueKey('username_${widget.profile.id}'), @@ -399,11 +431,8 @@ class _EditProfileSheetState extends ConsumerState<_EditProfileSheet> { ), ], ), - ), - const SizedBox(height: 28), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 32), - child: Row( + const SizedBox(height: 28), + Row( children: [ Expanded( child: OutlinedButton( @@ -426,8 +455,8 @@ class _EditProfileSheetState extends ConsumerState<_EditProfileSheet> { ), ], ), - ), - ], + ], + ), ), ), ); @@ -449,12 +478,9 @@ class _EditProfileSheetState extends ConsumerState<_EditProfileSheet> { } catch (e) { if (!mounted) return; setState(() => _saving = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(_errorMessage(e)), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(_errorMessage(e)))); } } @@ -462,31 +488,8 @@ class _EditProfileSheetState extends ConsumerState<_EditProfileSheet> { final s = error.toString(); if (s.contains('Username already taken')) return 'Username already taken'; if (s.contains('Username must be')) { - return 'Username must be 3-20 characters, alphanumeric, underscores, or dots only'; + return 'Username must be 3-20 characters: letters, numbers, _ or .'; } return 'Failed to save: $s'; } } - -// ── Shared ──────────────────────────────────────────────────────────────────── - -class _DragHandle extends StatelessWidget { - const _DragHandle(); - - @override - Widget build(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Container( - width: 32, - height: 4, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - ); - } -} diff --git a/lib/features/rating/presentation/extensions/rating_ui.dart b/lib/features/rating/presentation/extensions/rating_ui.dart index 531242e..5c988de 100644 --- a/lib/features/rating/presentation/extensions/rating_ui.dart +++ b/lib/features/rating/presentation/extensions/rating_ui.dart @@ -1,4 +1,6 @@ import 'package:eigen_api/eigen_api.dart'; +import 'package:eigen_flutter/core/theme/app_semantic_colors.dart'; +import 'package:flutter/material.dart'; /// Display helpers for [Rating]. extension RatingUi on Rating { @@ -6,3 +8,21 @@ extension RatingUi on Rating { String get poolLabel => pool.isEmpty ? pool : pool[0].toUpperCase() + pool.substring(1); } + +/// Display helpers for a change in a player's rating. +extension RatingDeltaUi on RatingDelta { + /// The semantic color for a gain, loss, or unchanged rating. + /// + /// A gain is a stable success role rather than the app's tertiary brand + /// color. Loss remains Material's built-in error role and an unchanged value + /// stays neutral. + Color color(ColorScheme colorScheme, {AppSemanticColors? semanticColors}) { + if (displayChange > 0) { + return (semanticColors ?? + AppSemanticColors.forBrightness(colorScheme.brightness)) + .success; + } + if (displayChange < 0) return colorScheme.error; + return colorScheme.onSurfaceVariant; + } +} diff --git a/lib/features/rating/presentation/widgets/player_ratings.dart b/lib/features/rating/presentation/widgets/player_ratings.dart index 2b0c9a4..9d93e13 100644 --- a/lib/features/rating/presentation/widgets/player_ratings.dart +++ b/lib/features/rating/presentation/widgets/player_ratings.dart @@ -82,7 +82,9 @@ class _RatingCards extends StatelessWidget { builder: (context, constraints) { final cardWidth = ratings.length == 1 ? constraints.maxWidth - : (constraints.maxWidth - 12) / 2; + : constraints.maxWidth >= 520 + ? (constraints.maxWidth - 12) / 2 + : constraints.maxWidth; return Wrap( spacing: 12, runSpacing: 12, diff --git a/lib/features/settings/presentation/screens/settings_screen.dart b/lib/features/settings/presentation/screens/settings_screen.dart index 98c553e..ca36d29 100644 --- a/lib/features/settings/presentation/screens/settings_screen.dart +++ b/lib/features/settings/presentation/screens/settings_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:eigen_flutter/core/config/app_config.dart'; +import 'package:eigen_flutter/core/adaptive/adaptive_layout.dart'; import 'package:eigen_flutter/core/errors/error_messages.dart'; import 'package:eigen_flutter/core/notifications/firebase_notification_service.dart'; import 'package:eigen_flutter/core/notifications/notification_provider.dart'; @@ -13,8 +14,9 @@ import 'package:eigen_flutter/core/utils/package_info_provider.dart'; import 'package:eigen_flutter/shared/widgets/made_by_credit.dart'; import 'package:eigen_flutter/features/auth/providers/auth_providers.dart'; import 'package:eigen_flutter/shared/providers/player_providers.dart'; +import 'package:eigen_flutter/shared/widgets/adaptive_single_choice.dart'; import 'package:eigen_flutter/shared/widgets/player_avatar.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:url_launcher/link.dart'; /// Settings screen with navigation to profile and app settings. class SettingsScreen extends ConsumerWidget { @@ -22,83 +24,77 @@ class SettingsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; final appHost = ref.watch(appConfigProvider).engine.appHost; - return ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - children: [ - // Profile Section - const _SectionHeader(title: 'Account'), - if (ref.watch(isAnonymousProvider)) const _UpgradeAccountCard(), - Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: ListTile( - leading: const _ProfileAvatarLeading(), - title: const Text('Edit Profile'), - subtitle: const Text('Update your profile details'), - trailing: const Icon(Icons.chevron_right), - onTap: () => context.push('/settings/profile'), + return ConstrainedContentPane( + maxWidth: 720, + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + // Profile Section + const _SectionHeader(title: 'Account'), + if (ref.watch(isAnonymousProvider)) const _UpgradeAccountCard(), + Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: ListTile( + leading: const _ProfileAvatarLeading(), + title: const Text('Edit Profile'), + subtitle: const Text('Update your profile details'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/settings/profile'), + ), ), - ), - const SizedBox(height: 16), + const SizedBox(height: 16), - // Preferences Section - const _SectionHeader(title: 'Preferences'), - Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Column( - children: [ - _ThemeSelector(), - const Divider(height: 1), - const _NotificationsSection(), - ], + // Preferences Section + const _SectionHeader(title: 'Preferences'), + Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Column( + children: [ + _ThemeSelector(), + const Divider(height: 1), + const _NotificationsSection(), + ], + ), ), - ), - const SizedBox(height: 16), + const SizedBox(height: 16), - // About Section - const _SectionHeader(title: 'About'), - Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Column( - children: [ - const _AppVersionTile(), - if (appHost != null) ...[ - const Divider(height: 1), - ListTile( - leading: Icon( - Icons.description_outlined, - color: colorScheme.onSurfaceVariant, + // About Section + const _SectionHeader(title: 'About'), + Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Column( + children: [ + const _AppVersionTile(), + if (appHost != null) ...[ + const Divider(height: 1), + _LegalListTile( + uri: legalPageUrl('/terms', appHost: appHost)!, + icon: Icons.description_outlined, + label: 'Terms of Service', ), - title: const Text('Terms of Service'), - trailing: const Icon(Icons.open_in_new, size: 18), - onTap: () => _launchLegalUrl('/terms', appHost: appHost), - ), - const Divider(height: 1), - ListTile( - leading: Icon( - Icons.privacy_tip_outlined, - color: colorScheme.onSurfaceVariant, + const Divider(height: 1), + _LegalListTile( + uri: legalPageUrl('/privacy', appHost: appHost)!, + icon: Icons.privacy_tip_outlined, + label: 'Privacy Policy', ), - title: const Text('Privacy Policy'), - trailing: const Icon(Icons.open_in_new, size: 18), - onTap: () => _launchLegalUrl('/privacy', appHost: appHost), - ), + ], ], - ], + ), ), - ), - const SizedBox(height: 32), + const SizedBox(height: 32), - const _DeleteAccountTile(), + const _DeleteAccountTile(), - const SizedBox(height: 32), + const SizedBox(height: 32), - const MadeByCredit(), + const MadeByCredit(), - const SizedBox(height: 16), - ], + const SizedBox(height: 16), + ], + ), ); } } @@ -405,33 +401,30 @@ class _ThemeSelector extends ConsumerWidget { ), const SizedBox(height: 12), themeAsync.when( - data: (currentTheme) => SizedBox( - width: double.infinity, - child: SegmentedButton( - segments: const [ - ButtonSegment( - value: ThemeMode.light, - icon: Icon(Icons.light_mode_outlined), - label: Text('Light'), - ), - ButtonSegment( - value: ThemeMode.system, - icon: Icon(Icons.phone_android_outlined), - label: Text('System'), - ), - ButtonSegment( - value: ThemeMode.dark, - icon: Icon(Icons.dark_mode_outlined), - label: Text('Dark'), - ), - ], - selected: {currentTheme}, - onSelectionChanged: (selection) { - ref - .read(themeControllerProvider.notifier) - .setTheme(selection.first); - }, - ), + data: (currentTheme) => AdaptiveSingleChoice( + choices: const [ + AdaptiveChoice( + value: ThemeMode.light, + icon: Icons.light_mode_outlined, + label: 'Light', + ), + AdaptiveChoice( + value: ThemeMode.system, + icon: Icons.brightness_auto_outlined, + label: 'System', + ), + AdaptiveChoice( + value: ThemeMode.dark, + icon: Icons.dark_mode_outlined, + label: 'Dark', + ), + ], + value: currentTheme, + label: 'Theme', + minimumSegmentWidth: 104, + onChanged: (theme) { + ref.read(themeControllerProvider.notifier).setTheme(theme); + }, ), loading: () => const Center( child: SizedBox(height: 48, child: CircularProgressIndicator()), @@ -506,13 +499,10 @@ class _UpgradeAccountCard extends ConsumerWidget { child: FilledButton( onPressed: isLoading ? null : () => _upgrade(context, ref), child: isLoading - ? SizedBox( + ? const SizedBox( width: 20, height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colorScheme.onPrimary, - ), + child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Create account'), ), @@ -527,7 +517,6 @@ class _UpgradeAccountCard extends ConsumerWidget { // Capture before the await, since on success this card is removed from the tree // (isAnonymous flips false), but the messenger ancestor survives. final messenger = ScaffoldMessenger.of(context); - final colorScheme = Theme.of(context).colorScheme; try { final outcome = await ref .read(authControllerProvider.notifier) @@ -562,10 +551,7 @@ class _UpgradeAccountCard extends ConsumerWidget { } } catch (e) { messenger.showSnackBar( - SnackBar( - content: Text('Could not create account: ${humanize(e)}'), - backgroundColor: colorScheme.error, - ), + SnackBar(content: Text('Could not create account: ${humanize(e)}')), ); } } @@ -580,6 +566,7 @@ Future showExistingAccountSwitchDialog(BuildContext context) async { return await showDialog( context: context, builder: (dialogContext) => AlertDialog( + scrollable: true, title: const Text('Sign in to your existing account?'), content: const Text( 'That Google account already exists. You can sign in to it, but ' @@ -642,6 +629,7 @@ class _DeleteAccountDialogState extends ConsumerState<_DeleteAccountDialog> { final colorScheme = Theme.of(context).colorScheme; return AlertDialog( + scrollable: true, title: const Text('Delete account?'), content: const Text( 'This permanently deletes your account, all your games, and your ' @@ -659,13 +647,10 @@ class _DeleteAccountDialogState extends ConsumerState<_DeleteAccountDialog> { ), onPressed: _deleting ? null : _deleteAccount, child: _deleting - ? SizedBox( + ? const SizedBox( width: 16, height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colorScheme.onError, - ), + child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Delete account'), ), @@ -682,19 +667,40 @@ class _DeleteAccountDialogState extends ConsumerState<_DeleteAccountDialog> { } catch (e) { if (!mounted) return; setState(() => _deleting = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to delete account: $e'), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to delete account: $e'))); } } } -Future _launchLegalUrl(String path, {required String? appHost}) async { - final uri = legalPageUrl(path, appHost: appHost); - if (uri != null) await launchUrl(uri, mode: LaunchMode.externalApplication); +class _LegalListTile extends StatelessWidget { + const _LegalListTile({ + required this.uri, + required this.icon, + required this.label, + }); + + final Uri uri; + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Link( + uri: uri, + target: LinkTarget.blank, + builder: (context, followLink) => ListTile( + leading: Icon( + icon, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + title: Text(label), + trailing: const Icon(Icons.open_in_new, size: 18), + onTap: followLink, + ), + ); + } } /// Shows the current user's profile avatar, falling back to a generic icon. @@ -731,13 +737,16 @@ class _SectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, + return Semantics( + header: true, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), ), ), ); diff --git a/lib/features/social/presentation/social_screen.dart b/lib/features/social/presentation/social_screen.dart index 2b3fc41..c79f5d2 100644 --- a/lib/features/social/presentation/social_screen.dart +++ b/lib/features/social/presentation/social_screen.dart @@ -2,7 +2,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:eigen_flutter/core/errors/error_messages.dart'; +import 'package:eigen_flutter/core/adaptive/adaptive_layout.dart'; import 'package:eigen_flutter/features/social/presentation/widgets/friend_actions.dart'; import 'package:eigen_flutter/features/social/presentation/widgets/friend_buttons.dart'; import 'package:eigen_flutter/features/social/presentation/widgets/player_profile_sheet.dart'; @@ -20,21 +22,83 @@ class SocialScreen extends ConsumerStatefulWidget { class _SocialScreenState extends ConsumerState with SingleTickerProviderStateMixin { + static const _tabNames = ['friends', 'requests', 'add']; + late TabController _tabController; + String? _selectedPlayerId; + bool _syncingFromRoute = false; + int? _directTabSelection; @override void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this); + _tabController.addListener(_handleControllerChange); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final routeIndex = _routeTabIndex(); + if (_tabController.index == routeIndex) return; + + _syncingFromRoute = true; + _directTabSelection = null; + _tabController.index = routeIndex; + _syncingFromRoute = false; } @override void dispose() { - _tabController.dispose(); + _tabController + ..removeListener(_handleControllerChange) + ..dispose(); super.dispose(); } - void _goToAddFriend() => _tabController.animateTo(2); + int _routeTabIndex() { + final tab = GoRouterState.of(context).uri.queryParameters['tab']; + final index = _tabNames.indexOf(tab ?? ''); + return index < 0 ? 0 : index; + } + + String _locationForTab(int index) { + final uri = GoRouterState.of(context).uri; + final query = Map.of(uri.queryParameters); + if (index == 0) { + query.remove('tab'); + } else { + query['tab'] = _tabNames[index]; + } + return uri.replace(queryParameters: query).toString(); + } + + void _selectTabFromUser(int index, {bool animate = false}) { + if (index == _routeTabIndex()) { + _directTabSelection = null; + return; + } + _directTabSelection = index; + if (animate && _tabController.index != index) { + _tabController.animateTo(index); + } + context.go(_locationForTab(index)); + } + + void _handleControllerChange() { + if (!mounted || _syncingFromRoute || _tabController.indexIsChanging) { + return; + } + final index = _tabController.index; + if (_directTabSelection == index) { + _directTabSelection = null; + return; + } + if (index == _routeTabIndex()) return; + Router.neglect(context, () => context.go(_locationForTab(index))); + } + + void _goToAddFriend() => _selectTabFromUser(2, animate: true); @override Widget build(BuildContext context) { @@ -43,38 +107,108 @@ class _SocialScreenState extends ConsumerState _ => 0, }; - return Column( - children: [ - TabBar( - controller: _tabController, - tabs: [ - const Tab(text: 'Friends'), - Tab( - child: Row( - mainAxisSize: MainAxisSize.min, + return AdaptiveLayoutBuilder( + builder: (context, constraints, windowClass) { + final expanded = windowClass.isAtLeastExpanded; + const expandedMasterPaneWidth = 440.0; + final tabBarWidth = expanded + ? expandedMasterPaneWidth + : constraints.maxWidth; + final scrollableTabs = tabBarWidth < 520; + final tabs = Column( + children: [ + Row( + children: [ + Expanded( + child: TabBar( + controller: _tabController, + onTap: _selectTabFromUser, + isScrollable: scrollableTabs, + tabAlignment: scrollableTabs + ? TabAlignment.start + : TabAlignment.fill, + tabs: [ + const Tab(text: 'Friends'), + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Requests'), + if (requestCount > 0) ...[ + const SizedBox(width: 6), + Badge.count(count: requestCount, smallSize: 14), + ], + ], + ), + ), + const Tab(text: 'Add Friend'), + ], + ), + ), + IconButton( + onPressed: () { + ref + ..invalidate(friendsProvider) + ..invalidate(friendRequestsProvider); + }, + icon: const Icon(Icons.refresh), + tooltip: 'Refresh social lists', + ), + const SizedBox(width: 8), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, children: [ - const Text('Requests'), - if (requestCount > 0) ...[ - const SizedBox(width: 6), - Badge.count(count: requestCount, smallSize: 14), - ], + _FriendsList( + onFindPlayers: _goToAddFriend, + selectedPlayerId: expanded ? _selectedPlayerId : null, + onPlayerSelected: expanded + ? (id) => setState(() => _selectedPlayerId = id) + : null, + ), + _PendingRequests( + selectedPlayerId: expanded ? _selectedPlayerId : null, + onPlayerSelected: expanded + ? (id) => setState(() => _selectedPlayerId = id) + : null, + ), + _AddFriend( + selectedPlayerId: expanded ? _selectedPlayerId : null, + onPlayerSelected: expanded + ? (id) => setState(() => _selectedPlayerId = id) + : null, + ), ], ), ), - const Tab(text: 'Add Friend'), ], - ), - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - _FriendsList(onFindPlayers: _goToAddFriend), - const _PendingRequests(), - const _AddFriend(), - ], - ), - ), - ], + ); + + if (!expanded) { + return ConstrainedContentPane(maxWidth: 720, child: tabs); + } + return Row( + children: [ + SizedBox(width: expandedMasterPaneWidth, child: tabs), + const VerticalDivider(width: 1), + Expanded( + child: _selectedPlayerId == null + ? const EmptyStateView( + icon: Icons.person_search_outlined, + title: 'Select a player', + message: + 'Choose someone to view their profile and ratings.', + ) + : PlayerProfilePanel( + playerId: _selectedPlayerId!, + type: SeatTypeEnum.human, + ), + ), + ], + ); + }, ); } } @@ -82,9 +216,15 @@ class _SocialScreenState extends ConsumerState // ── Friends list ────────────────────────────────────────────────────────────── class _FriendsList extends ConsumerStatefulWidget { - const _FriendsList({required this.onFindPlayers}); + const _FriendsList({ + required this.onFindPlayers, + required this.selectedPlayerId, + required this.onPlayerSelected, + }); final VoidCallback onFindPlayers; + final String? selectedPlayerId; + final ValueChanged? onPlayerSelected; @override ConsumerState<_FriendsList> createState() => _FriendsListState(); @@ -132,6 +272,9 @@ class _FriendsListState extends ConsumerState<_FriendsList> username: friendships[index].username, avatarUrl: friendships[index].avatarUrl, variant: _FriendListVariant.friends, + selected: + widget.selectedPlayerId == friendships[index].userId, + onPlayerSelected: widget.onPlayerSelected, ), ), ), @@ -161,6 +304,8 @@ class _FriendListTile extends StatelessWidget { required this.username, required this.avatarUrl, required this.variant, + required this.selected, + required this.onPlayerSelected, }); final String userId; @@ -168,15 +313,20 @@ class _FriendListTile extends StatelessWidget { final String username; final String? avatarUrl; final _FriendListVariant variant; + final bool selected; + final ValueChanged? onPlayerSelected; @override Widget build(BuildContext context) { return ListTile( - onTap: () => PlayerProfileSheet.show( - context, - playerId: userId, - type: SeatTypeEnum.human, - ), + selected: selected, + onTap: onPlayerSelected == null + ? () => PlayerProfileSheet.show( + context, + playerId: userId, + type: SeatTypeEnum.human, + ) + : () => onPlayerSelected!(userId), leading: PlayerAvatar(avatarUrl: avatarUrl, radius: 20), title: Text(displayName), subtitle: Text( @@ -204,7 +354,13 @@ class _FriendListTile extends StatelessWidget { // ── Pending requests ────────────────────────────────────────────────────────── class _PendingRequests extends ConsumerStatefulWidget { - const _PendingRequests(); + const _PendingRequests({ + required this.selectedPlayerId, + required this.onPlayerSelected, + }); + + final String? selectedPlayerId; + final ValueChanged? onPlayerSelected; @override ConsumerState<_PendingRequests> createState() => _PendingRequestsState(); @@ -223,7 +379,7 @@ class _PendingRequestsState extends ConsumerState<_PendingRequests> return requestsAsync.when( skipLoadingOnReload: true, data: (requests) => RefreshIndicator( - onRefresh: () async => ref.invalidate(friendsProvider), + onRefresh: () async => ref.invalidate(friendRequestsProvider), child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ @@ -248,6 +404,8 @@ class _PendingRequestsState extends ConsumerState<_PendingRequests> username: requests[index].username, avatarUrl: requests[index].avatarUrl, variant: _FriendListVariant.requests, + selected: widget.selectedPlayerId == requests[index].userId, + onPlayerSelected: widget.onPlayerSelected, ), ), ), @@ -263,7 +421,13 @@ class _PendingRequestsState extends ConsumerState<_PendingRequests> // ── Add friend ──────────────────────────────────────────────────────────────── class _AddFriend extends ConsumerStatefulWidget { - const _AddFriend(); + const _AddFriend({ + required this.selectedPlayerId, + required this.onPlayerSelected, + }); + + final String? selectedPlayerId; + final ValueChanged? onPlayerSelected; @override ConsumerState<_AddFriend> createState() => _AddFriendState(); @@ -324,6 +488,7 @@ class _AddFriendState extends ConsumerState<_AddFriend> IconButton( icon: const Icon(Icons.search), onPressed: _isLoading ? null : _search, + tooltip: 'Search players', ), ], onSubmitted: (_) => _search(), @@ -339,11 +504,14 @@ class _AddFriendState extends ConsumerState<_AddFriend> final user = _results[index]; return ListTile( key: ValueKey(user.id), - onTap: () => PlayerProfileSheet.show( - context, - playerId: user.id, - type: SeatTypeEnum.human, - ), + selected: widget.selectedPlayerId == user.id, + onTap: widget.onPlayerSelected == null + ? () => PlayerProfileSheet.show( + context, + playerId: user.id, + type: SeatTypeEnum.human, + ) + : () => widget.onPlayerSelected!(user.id), leading: PlayerAvatar(avatarUrl: user.avatarUrl, radius: 20), title: Text(user.displayName), subtitle: Text('@${user.username}'), diff --git a/lib/features/social/presentation/widgets/friend_buttons.dart b/lib/features/social/presentation/widgets/friend_buttons.dart index 5fd3838..210a190 100644 --- a/lib/features/social/presentation/widgets/friend_buttons.dart +++ b/lib/features/social/presentation/widgets/friend_buttons.dart @@ -115,17 +115,23 @@ class AcceptRequestButton extends ConsumerWidget { ? IconButton( icon: Icon(Icons.check, color: colorScheme.primary), onPressed: accept, + tooltip: 'Accept friend request', ) : FilledButton(onPressed: accept, child: const Text('Accept')), MutationPending() => compact - ? IconButton(icon: const _ButtonSpinner(size: 20), onPressed: null) + ? IconButton( + icon: const _ButtonSpinner(size: 20), + onPressed: null, + tooltip: 'Accepting friend request', + ) : FilledButton(onPressed: null, child: const _ButtonSpinner()), MutationSuccess() => compact ? IconButton( icon: Icon(Icons.check, color: colorScheme.primary), onPressed: null, + tooltip: 'Friend request accepted', ) : const FilledButton(onPressed: null, child: Text('Accepted')), MutationError() => @@ -133,6 +139,7 @@ class AcceptRequestButton extends ConsumerWidget { ? IconButton( icon: Icon(Icons.refresh, color: colorScheme.error), onPressed: accept, + tooltip: 'Retry accepting friend request', ) : FilledButton(onPressed: accept, child: const Text('Retry')), }; @@ -179,19 +186,25 @@ class DeclineRequestButton extends ConsumerWidget { ? IconButton( icon: Icon(Icons.close, color: colorScheme.error), onPressed: decline, + tooltip: 'Decline friend request', ) : OutlinedButton(onPressed: decline, child: const Text('Decline')), MutationPending() || MutationSuccess() => compact - ? IconButton(icon: const _ButtonSpinner(size: 20), onPressed: null) + ? IconButton( + icon: const _ButtonSpinner(size: 20), + onPressed: null, + tooltip: 'Declining friend request', + ) : const OutlinedButton(onPressed: null, child: _ButtonSpinner()), MutationError() => compact ? IconButton( - icon: Icon(Icons.close, color: colorScheme.error), + icon: Icon(Icons.refresh, color: colorScheme.error), onPressed: decline, + tooltip: 'Retry declining friend request', ) - : OutlinedButton(onPressed: decline, child: const Text('Decline')), + : OutlinedButton(onPressed: decline, child: const Text('Retry')), }; } } @@ -269,20 +282,44 @@ class _RemoveFriendButtonState extends ConsumerState { final disabled = _confirming || mutState.isPending; return switch (mutState) { - MutationIdle() || MutationError() => + MutationIdle() => widget.compact ? IconButton( icon: const Icon(Icons.person_remove), onPressed: disabled ? null : _remove, + tooltip: 'Remove friend', ) : TextButton( onPressed: disabled ? null : _remove, child: const Text('Remove'), ), - MutationPending() || MutationSuccess() => + MutationError() => widget.compact - ? IconButton(icon: const _ButtonSpinner(size: 20), onPressed: null) + ? IconButton( + icon: const Icon(Icons.refresh), + onPressed: disabled ? null : _remove, + tooltip: 'Retry removing friend', + ) + : TextButton( + onPressed: disabled ? null : _remove, + child: const Text('Retry'), + ), + MutationPending() => + widget.compact + ? IconButton( + icon: const _ButtonSpinner(size: 20), + onPressed: null, + tooltip: 'Removing friend', + ) : const TextButton(onPressed: null, child: _ButtonSpinner()), + MutationSuccess() => + widget.compact + ? const IconButton( + icon: Icon(Icons.person_remove), + onPressed: null, + tooltip: 'Friend removed', + ) + : const TextButton(onPressed: null, child: Text('Removed')), }; } } diff --git a/lib/features/social/presentation/widgets/player_profile_sheet.dart b/lib/features/social/presentation/widgets/player_profile_sheet.dart index 5a44301..8fb9c91 100644 --- a/lib/features/social/presentation/widgets/player_profile_sheet.dart +++ b/lib/features/social/presentation/widgets/player_profile_sheet.dart @@ -8,36 +8,23 @@ import 'package:eigen_flutter/features/game/providers/game_providers.dart'; import 'package:eigen_flutter/features/rating/presentation/widgets/player_ratings.dart'; import 'package:eigen_flutter/features/social/presentation/widgets/friend_actions.dart'; import 'package:eigen_api/eigen_api.dart'; +import 'package:eigen_flutter/core/theme/app_semantic_colors.dart'; import 'package:eigen_flutter/shared/providers/player_providers.dart'; import 'package:eigen_flutter/shared/widgets/player_avatar.dart'; import 'package:eigen_flutter/shared/widgets/player_tags.dart'; -/// Modal bottom sheet showing a player's public profile. +/// Backwards-compatible modal presentation for [PlayerProfilePanel]. /// -/// Displays identity, ratings across all pools, and, for registered human -/// players, friendship status with actions to add, accept, decline, or -/// remove. Bots and anonymous guests show identity and ratings only: bots -/// have no social features, and guests cannot be friended (server-enforced), -/// so the sheet tags them and omits the section. -/// -/// Use [PlayerProfileSheet.show] to present the sheet. -class PlayerProfileSheet extends ConsumerWidget { +/// Prefer [PlayerProfilePanel] when embedding the profile in an expanded web +/// layout. Existing callers can continue to use [show] for a compact modal. +class PlayerProfileSheet extends PlayerProfilePanel { const PlayerProfileSheet({ super.key, - required this.playerId, - required this.type, - required this.scrollController, + required super.playerId, + required super.type, + required super.scrollController, }); - /// The player's UUID (human or bot). - final String playerId; - - /// Whether this player is a human or bot. - final SeatTypeEnum type; - - /// Scroll controller from the enclosing [DraggableScrollableSheet]. - final ScrollController scrollController; - /// Shows the profile sheet for [playerId] as a modal bottom sheet. static void show( BuildContext context, { @@ -48,87 +35,100 @@ class PlayerProfileSheet extends ConsumerWidget { context: context, isScrollControlled: true, useSafeArea: true, - backgroundColor: Colors.transparent, - builder: (_) => DraggableScrollableSheet( + // Retain the framework Material so M3 supplies the surface, shape, ink, + // and its compact-versus-wide maximum width behavior. + showDragHandle: true, + builder: (sheetContext) => DraggableScrollableSheet( initialChildSize: 0.5, minChildSize: 0.35, maxChildSize: 0.9, expand: false, - builder: (_, scrollController) => PlayerProfileSheet( + builder: (_, scrollController) => PlayerProfilePanel( playerId: playerId, type: type, scrollController: scrollController, + onBeforeReplay: () => Navigator.of(sheetContext).pop(), ), ), ); } +} + +/// Reusable content panel for a player's public profile. +/// +/// Displays identity, ratings across all pools, and, for registered human +/// players, friendship status with actions to add, accept, decline, or remove. +/// Bots and anonymous guests show identity and ratings only. Embed this widget +/// in a bounded detail pane for expanded layouts, or use +/// [PlayerProfileSheet.show] for the compact modal presentation. +class PlayerProfilePanel extends ConsumerWidget { + const PlayerProfilePanel({ + super.key, + required this.playerId, + required this.type, + this.scrollController, + this.onBeforeReplay, + }); + + /// The player's UUID (human or bot). + final String playerId; + + /// Whether this player is a human or bot. + final SeatTypeEnum type; + + /// Optional controller for the panel's scroll view. + /// + /// The modal presentation passes its [DraggableScrollableSheet] controller; + /// inline callers may omit this or supply a controller owned by their pane. + final ScrollController? scrollController; + + /// Presentation hook invoked immediately before opening a replay. + /// + /// The modal sheet uses this to dismiss itself. Embedded panels leave it null + /// so replay navigation does not accidentally pop their containing route. + final VoidCallback? onBeforeReplay; @override Widget build(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; final AsyncValue playerAsync = ref.watch( playerInfoCacheProvider(id: playerId), ); - return ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), - child: ColoredBox( - color: colorScheme.surface, - child: CustomScrollView( - controller: scrollController, - slivers: [ - const SliverToBoxAdapter(child: _DragHandle()), - SliverToBoxAdapter( - child: playerAsync.when( - data: (player) => _Header(player: player, type: type), - loading: () => const Padding( - padding: EdgeInsets.symmetric(vertical: 48), - child: Center(child: CircularProgressIndicator()), - ), - error: (e, _) => const _DeletedPlayerHeader(), - ), - ), - SliverToBoxAdapter( - child: _SheetSection( - title: 'Ratings', - child: PlayerRatings.forPlayer(playerId), - ), + return CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: playerAsync.when( + data: (player) => _Header(player: player, type: type), + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 48), + child: Center(child: CircularProgressIndicator()), ), - SliverToBoxAdapter( - child: _RecentGamesSection(playerId: playerId, type: type), - ), - // Social section only for resolved, registered humans. While - // identity is still loading (or failed: deleted account) the - // section stays hidden rather than flashing an Add Friend button - // at a player who may turn out to be a guest. - if (type == SeatTypeEnum.human && - playerAsync.value?.isAnonymous == false) - SliverToBoxAdapter(child: _SocialSection(playerId: playerId)), - const SliverToBoxAdapter(child: SizedBox(height: 32)), - ], + error: (e, _) => const _DeletedPlayerHeader(), + ), ), - ), - ); - } -} - -class _DragHandle extends StatelessWidget { - const _DragHandle(); - - @override - Widget build(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Container( - width: 32, - height: 4, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), + SliverToBoxAdapter( + child: _SheetSection( + title: 'Ratings', + child: PlayerRatings.forPlayer(playerId), ), ), - ), + SliverToBoxAdapter( + child: _RecentGamesSection( + playerId: playerId, + type: type, + onBeforeReplay: onBeforeReplay, + ), + ), + // Social section only for resolved, registered humans. While + // identity is still loading (or failed: deleted account) the + // section stays hidden rather than flashing an Add Friend button + // at a player who may turn out to be a guest. + if (type == SeatTypeEnum.human && + playerAsync.value?.isAnonymous == false) + SliverToBoxAdapter(child: _SocialSection(playerId: playerId)), + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], ); } } @@ -259,10 +259,15 @@ class _SheetSection extends StatelessWidget { /// Hidden entirely when the player has no public finished games (or the fetch /// fails), so a player with nothing to show adds no empty chrome to the sheet. class _RecentGamesSection extends ConsumerWidget { - const _RecentGamesSection({required this.playerId, required this.type}); + const _RecentGamesSection({ + required this.playerId, + required this.type, + required this.onBeforeReplay, + }); final String playerId; final SeatTypeEnum type; + final VoidCallback? onBeforeReplay; @override Widget build(BuildContext context, WidgetRef ref) { @@ -281,7 +286,11 @@ class _RecentGamesSection extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final game in games) - _RecentGameRow(game: game, result: _resultFor(game, playerId)), + _RecentGameRow( + game: game, + result: _resultFor(game, playerId), + onBeforeReplay: onBeforeReplay, + ), ], ), ); @@ -305,10 +314,15 @@ OutcomeResultEnum? _resultFor(GameSummary game, String playerId) { } class _RecentGameRow extends StatelessWidget { - const _RecentGameRow({required this.game, required this.result}); + const _RecentGameRow({ + required this.game, + required this.result, + required this.onBeforeReplay, + }); final GameSummary game; final OutcomeResultEnum? result; + final VoidCallback? onBeforeReplay; @override Widget build(BuildContext context) { @@ -322,7 +336,13 @@ class _RecentGameRow extends StatelessWidget { return ListTile( contentPadding: EdgeInsets.zero, - leading: Icon(result.icon, color: result.color(colorScheme)), + leading: Icon( + result.icon, + color: result.color( + colorScheme, + semanticColors: AppSemanticColors.of(context), + ), + ), title: Text( 'Game #${game.id.substring(0, 8)}', style: textTheme.bodyLarge, @@ -333,10 +353,11 @@ class _RecentGameRow extends StatelessWidget { ), trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant), onTap: () { - // Capture the router before closing the sheet; the row's context is - // torn down by the pop. + // Capture the router before a modal presentation dismisses itself; its + // row context is torn down by the pop. Embedded panels have no dismiss + // hook and preserve their containing navigation stack. final router = GoRouter.of(context); - Navigator.of(context).pop(); + onBeforeReplay?.call(); router.pushNamed('replay', pathParameters: {'gameId': game.id}); }, ); diff --git a/lib/shared/widgets/adaptive_single_choice.dart b/lib/shared/widgets/adaptive_single_choice.dart new file mode 100644 index 0000000..61e30cc --- /dev/null +++ b/lib/shared/widgets/adaptive_single_choice.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; + +/// One labelled value rendered by [AdaptiveSingleChoice]. +@immutable +class AdaptiveChoice { + /// Creates a choice with optional leading [icon]. + const AdaptiveChoice({ + required this.value, + required this.label, + this.icon, + this.enabled = true, + }); + + /// Value reported when this choice is selected. + final T value; + + /// Short, user-facing label. + final String label; + + /// Optional icon shared by the segmented and menu presentations. + final IconData? icon; + + /// Whether this individual choice can be selected. + final bool enabled; +} + +/// Uses segments when they fit and a Material 3 menu when they do not. +/// +/// [SegmentedButton] is most effective for two to five short, simultaneously +/// visible choices. A whitelabel game can supply more or longer choices, so the +/// control falls back to [DropdownMenu] before overflowing its available width. +class AdaptiveSingleChoice extends StatelessWidget { + /// Creates a width-aware, single-selection Material control. + const AdaptiveSingleChoice({ + super.key, + required this.choices, + required this.value, + required this.onChanged, + this.enabled = true, + this.label, + this.minimumSegmentWidth = 88, + }) : assert(choices.length > 1); + + /// Available choices in display order. + final List> choices; + + /// Currently selected value. + final T value; + + /// Called when a different enabled choice is selected. + final ValueChanged onChanged; + + /// Whether the control accepts input. + final bool enabled; + + /// Optional field label used by the menu presentation. + final String? label; + + /// Estimated minimum width per segment before switching to a menu. + final double minimumSegmentWidth; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + final segmentWidth = choices.length * minimumSegmentWidth; + final segmentsFit = + choices.length <= 5 && + (!availableWidth.isFinite || availableWidth >= segmentWidth); + + if (segmentsFit) { + return SegmentedButton( + showSelectedIcon: false, + segments: [ + for (final choice in choices) + ButtonSegment( + value: choice.value, + label: Text(choice.label), + icon: choice.icon == null ? null : Icon(choice.icon), + enabled: enabled && choice.enabled, + ), + ], + selected: {value}, + onSelectionChanged: enabled + ? (selection) => onChanged(selection.first) + : null, + ); + } + + return DropdownMenu( + key: ValueKey(value), + initialSelection: value, + enabled: enabled, + expandedInsets: EdgeInsets.zero, + label: label == null ? null : Text(label!), + dropdownMenuEntries: [ + for (final choice in choices) + DropdownMenuEntry( + value: choice.value, + label: choice.label, + enabled: choice.enabled, + leadingIcon: choice.icon == null ? null : Icon(choice.icon), + ), + ], + onSelected: (selection) { + if (selection != null) onChanged(selection); + }, + ); + }, + ); + } +} diff --git a/lib/shared/widgets/made_by_credit.dart b/lib/shared/widgets/made_by_credit.dart index 943a400..3c9e277 100644 --- a/lib/shared/widgets/made_by_credit.dart +++ b/lib/shared/widgets/made_by_credit.dart @@ -1,7 +1,6 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:url_launcher/link.dart'; import '../../core/config/app_config.dart'; @@ -13,33 +12,16 @@ final _creditUrl = Uri.parse('https://eigeninteractive.com'); /// The credit line at the foot of the settings and about screens. /// -/// Only the brand name inside the line is tappable, marked by colour rather -/// than an underline, because the sentence around it is prose and does not point -/// anywhere. A custom [Branding.madeByCredit] that never mentions the engine -/// renders as plain text, so an app that replaced the line does not silently -/// link its own words to us. -/// -/// Stateful only to own the recognizer: a [TapGestureRecognizer] holds -/// resources and must be disposed, which a build method cannot do. -class MadeByCredit extends ConsumerStatefulWidget { +/// Only the brand name inside the line is linked. It is underlined as well as +/// coloured so the affordance does not depend on colour perception, and the +/// Material text button supplies keyboard focus and hover feedback. A custom +/// [Branding.madeByCredit] that never mentions the engine renders as plain text, +/// so an app that replaced the line does not silently link its own words to us. +class MadeByCredit extends ConsumerWidget { const MadeByCredit({super.key}); @override - ConsumerState createState() => _MadeByCreditState(); -} - -class _MadeByCreditState extends ConsumerState { - late final TapGestureRecognizer _tap = TapGestureRecognizer() - ..onTap = () => launchUrl(_creditUrl, mode: LaunchMode.externalApplication); - - @override - void dispose() { - _tap.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final credit = ref.watch(appConfigProvider).branding.madeByCredit; final style = theme.textTheme.bodySmall?.copyWith( @@ -56,20 +38,42 @@ class _MadeByCreditState extends ConsumerState { padding: const EdgeInsets.all(16), child: at == -1 ? Text(credit, style: style, textAlign: TextAlign.center) - : Text.rich( - TextSpan( - style: style, - children: [ - if (before.isNotEmpty) TextSpan(text: before), - TextSpan( - text: _creditBrand, - style: TextStyle(color: theme.colorScheme.primary), - recognizer: _tap, + : Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (before.isNotEmpty) Text(before, style: style), + Link( + uri: _creditUrl, + target: LinkTarget.blank, + builder: (context, followLink) => Semantics( + link: true, + linkUrl: _creditUrl, + label: _creditBrand, + enabled: followLink != null, + focusable: followLink != null, + onTap: followLink, + excludeSemantics: true, + child: TextButton( + onPressed: followLink, + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.primary, + minimumSize: const Size(48, 48), + padding: const EdgeInsets.symmetric(horizontal: 4), + ), + child: Text( + _creditBrand, + style: style?.copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: theme.colorScheme.primary, + ), + ), + ), ), - if (after.isNotEmpty) TextSpan(text: after), - ], - ), - textAlign: TextAlign.center, + ), + if (after.isNotEmpty) Text(after, style: style), + ], ), ); } diff --git a/lib/shared/widgets/player_avatar.dart b/lib/shared/widgets/player_avatar.dart index df1737c..a2039b5 100644 --- a/lib/shared/widgets/player_avatar.dart +++ b/lib/shared/widgets/player_avatar.dart @@ -33,6 +33,7 @@ class PlayerAvatar extends ConsumerWidget { this.isBot = false, this.showBorder = false, this.borderColor, + this.semanticLabel, this.onTap, }); @@ -52,6 +53,15 @@ class PlayerAvatar extends ConsumerWidget { /// Color of the border ring. Defaults to the theme's primary color. final Color? borderColor; + /// Accessible description for the avatar or its action. + /// + /// For an interactive avatar, describe the action, for example "Open Ada's + /// profile". Interactive avatars fall back to "Open player profile" (or + /// "Open bot profile") so they are never exposed as an unnamed control. + /// Non-interactive avatars are decorative unless this is supplied, because + /// their player's name is normally already adjacent to them. + final String? semanticLabel; + /// Optional tap callback. If null, the avatar is non-interactive. final VoidCallback? onTap; @@ -68,10 +78,53 @@ class PlayerAvatar extends ConsumerWidget { borderColor: borderColor ?? Theme.of(context).colorScheme.primary, ); - if (onTap != null) { - return GestureDetector(onTap: onTap, child: avatar); + final handleTap = onTap; + if (handleTap != null) { + final actionLabel = + semanticLabel ?? (isBot ? 'Open bot profile' : 'Open player profile'); + + return Semantics( + button: true, + enabled: true, + label: actionLabel, + child: Tooltip( + message: actionLabel, + excludeFromSemantics: true, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 48, minHeight: 48), + child: Stack( + alignment: Alignment.center, + children: [ + ExcludeSemantics(child: avatar), + Positioned.fill( + child: Material( + type: MaterialType.transparency, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkResponse( + onTap: handleTap, + containedInkWell: true, + customBorder: const CircleBorder(), + child: const SizedBox.expand(), + ), + ), + ), + ], + ), + ), + ), + ); } - return avatar; + + final label = semanticLabel; + return label == null + ? ExcludeSemantics(child: avatar) + : Semantics( + image: true, + label: label, + excludeSemantics: true, + child: avatar, + ); } } diff --git a/lib/shared/widgets/status_banner.dart b/lib/shared/widgets/status_banner.dart index b9e6ef6..589cc0b 100644 --- a/lib/shared/widgets/status_banner.dart +++ b/lib/shared/widgets/status_banner.dart @@ -1,3 +1,5 @@ +import 'dart:ui' show SemanticsRole; + import 'package:flutter/material.dart'; /// A full-width slim banner for surfacing system-level status (e.g. offline, @@ -18,21 +20,27 @@ class StatusBanner extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - width: double.infinity, - color: backgroundColor, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - leading, - const SizedBox(width: 10), - Text( - label, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: foregroundColor), - ), - ], + return Semantics( + container: true, + role: SemanticsRole.status, + label: label, + excludeSemantics: true, + child: Container( + width: double.infinity, + color: backgroundColor, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + leading, + const SizedBox(width: 10), + Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: foregroundColor), + ), + ], + ), ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index 540fcf4..ed3a442 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -100,6 +100,9 @@ flutter: assets: - assets/vendor/cropperjs/ + - assets/google/ + - fonts/OFL-Inter.txt + - fonts/OFL-SpaceGrotesk.txt # Both faces are bundled as package fonts, so a consuming app gets them # automatically and they render offline from the first frame with no runtime diff --git a/test/core/adaptive/adaptive_layout_test.dart b/test/core/adaptive/adaptive_layout_test.dart new file mode 100644 index 0000000..e89b4b1 --- /dev/null +++ b/test/core/adaptive/adaptive_layout_test.dart @@ -0,0 +1,111 @@ +import 'package:eigen_flutter/core/adaptive/adaptive_layout.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('classifies Material window breakpoints at their boundaries', () { + expect(AppWindowClass.fromWidth(599), AppWindowClass.compact); + expect(AppWindowClass.fromWidth(600), AppWindowClass.medium); + expect(AppWindowClass.fromWidth(839), AppWindowClass.medium); + expect(AppWindowClass.fromWidth(840), AppWindowClass.expanded); + expect(AppWindowClass.fromWidth(1199), AppWindowClass.expanded); + expect(AppWindowClass.fromWidth(1200), AppWindowClass.large); + expect(AppWindowClass.fromWidth(1599), AppWindowClass.large); + expect(AppWindowClass.fromWidth(1600), AppWindowClass.extraLarge); + }); + + testWidgets('builder responds to its local available width', (tester) async { + Future pumpAt(double width) => tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: width, + child: AdaptiveLayoutBuilder( + builder: (_, _, windowClass) => Text(windowClass.name), + ), + ), + ), + ), + ); + + await pumpAt(500); + expect(find.text('compact'), findsOneWidget); + + await pumpAt(700); + expect(find.text('medium'), findsOneWidget); + }); + + testWidgets('constrained pane caps readable content width', (tester) async { + final childKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: SizedBox( + width: 1200, + child: ConstrainedContentPane( + maxWidth: 720, + child: ColoredBox(key: childKey, color: Colors.blue), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(childKey)).width, 720); + }); + + test('responsive grid stays single-column until its content threshold', () { + final compact = + responsiveCardGridDelegate( + availableWidth: 700, + maxCrossAxisExtent: 560, + mainAxisExtent: 112, + ) + as SliverGridDelegateWithMaxCrossAxisExtent; + final expanded = + responsiveCardGridDelegate( + availableWidth: 1000, + maxCrossAxisExtent: 560, + mainAxisExtent: 112, + ) + as SliverGridDelegateWithMaxCrossAxisExtent; + + expect(compact.maxCrossAxisExtent, 700); + expect(expanded.maxCrossAxisExtent, 560); + }); + + group('large text card layout', () { + test('uses a grid for expanded windows at the default text size', () { + expect( + shouldUseCardGrid( + windowClass: AppWindowClass.expanded, + textScaler: TextScaler.noScaling, + ), + isTrue, + ); + }); + + test('uses an intrinsically sized list when text is enlarged', () { + expect( + shouldUseCardGrid( + windowClass: AppWindowClass.extraLarge, + textScaler: const TextScaler.linear(1.3), + ), + isFalse, + ); + }); + + test('does not use a grid in compact or medium windows', () { + for (final windowClass in [ + AppWindowClass.compact, + AppWindowClass.medium, + ]) { + expect( + shouldUseCardGrid( + windowClass: windowClass, + textScaler: TextScaler.noScaling, + ), + isFalse, + ); + } + }); + }); +} diff --git a/test/core/config/web_metadata_test.dart b/test/core/config/web_metadata_test.dart new file mode 100644 index 0000000..d5f24de --- /dev/null +++ b/test/core/config/web_metadata_test.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('reference web shell declares its language and install metadata', () { + final index = File('example/web/index.html').readAsStringSync(); + final manifest = + jsonDecode(File('example/web/manifest.json').readAsStringSync()) + as Map; + + expect(index, contains('')); + expect(index, contains('; + expect(icons, isNotEmpty); + expect( + icons.where( + (value) => (value as Map)['purpose'] == 'maskable', + ), + isNotEmpty, + ); + for (final value in icons) { + final icon = value as Map; + expect(icon['src'], isNotEmpty); + expect(icon['sizes'], isNotEmpty); + expect(icon['type'], startsWith('image/')); + expect(File('example/web/${icon['src']}').existsSync(), isTrue); + } + }); +} diff --git a/test/core/navigation/app_route_title_test.dart b/test/core/navigation/app_route_title_test.dart new file mode 100644 index 0000000..189e9ed --- /dev/null +++ b/test/core/navigation/app_route_title_test.dart @@ -0,0 +1,84 @@ +import 'package:eigen_flutter/core/navigation/widgets/app_route_title.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +void main() { + test('maps routed URIs to contextual page names', () { + expect(appPageNameForUri(Uri.parse('/home')), 'Home'); + expect(appPageNameForUri(Uri.parse('/lobby?tab=friends')), 'Lobby'); + expect(appPageNameForUri(Uri.parse('/game/42')), 'Game'); + expect(appPageNameForUri(Uri.parse('/game/42/replay')), 'Replay'); + expect(appPageNameForUri(Uri.parse('/missing')), 'Page not found'); + }); + + testWidgets('follows preserved shell branches and root overlays', ( + tester, + ) async { + final rootKey = GlobalKey(); + final router = GoRouter( + navigatorKey: rootKey, + initialLocation: '/home', + routes: [ + StatefulShellRoute.indexedStack( + parentNavigatorKey: rootKey, + builder: (_, _, shell) => shell, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/home', + builder: (_, _) => const Text('Home page'), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/lobby', + builder: (_, _) => const Text('Lobby page'), + ), + ], + ), + ], + ), + GoRoute( + path: '/game/:gameId', + parentNavigatorKey: rootKey, + builder: (_, _) => const Text('Game page'), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + builder: (context, child) => + AppRouteTitle(router: router, appName: 'Eigen', child: child!), + ), + ); + + String title() => tester + .widget(find.byKey(const ValueKey('app-route-title'))) + .title; + + expect(title(), 'Home · Eigen'); + + router.go('/lobby'); + await tester.pumpAndSettle(); + expect(title(), 'Lobby · Eigen'); + + router.push('/game/42'); + await tester.pumpAndSettle(); + expect(title(), 'Game · Eigen'); + + router.pop(); + await tester.pumpAndSettle(); + expect(title(), 'Lobby · Eigen'); + + router.go('/home'); + await tester.pumpAndSettle(); + expect(title(), 'Home · Eigen'); + }); +} diff --git a/test/core/navigation/not_found_screen_test.dart b/test/core/navigation/not_found_screen_test.dart new file mode 100644 index 0000000..4ef265b --- /dev/null +++ b/test/core/navigation/not_found_screen_test.dart @@ -0,0 +1,25 @@ +import 'package:eigen_flutter/core/navigation/widgets/not_found_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +void main() { + testWidgets('explains an unknown URL and returns home', (tester) async { + final router = GoRouter( + initialLocation: '/missing-page', + routes: [GoRoute(path: '/home', builder: (_, _) => const Text('HOME'))], + errorBuilder: (_, state) => + NotFoundScreen(location: state.uri.toString()), + ); + addTearDown(router.dispose); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + + expect(find.text('Page not found'), findsOneWidget); + expect(find.text('There is no page at /missing-page.'), findsOneWidget); + + await tester.tap(find.text('Go home')); + await tester.pumpAndSettle(); + expect(find.text('HOME'), findsOneWidget); + }); +} diff --git a/test/core/navigation/shell_scaffold_adaptive_test.dart b/test/core/navigation/shell_scaffold_adaptive_test.dart new file mode 100644 index 0000000..5fa5fb3 --- /dev/null +++ b/test/core/navigation/shell_scaffold_adaptive_test.dart @@ -0,0 +1,64 @@ +import 'package:eigen_api/eigen_api.dart'; +import 'package:eigen_flutter/core/connectivity/connectivity_provider.dart'; +import 'package:eigen_flutter/core/navigation/widgets/shell_scaffold.dart'; +import 'package:eigen_flutter/features/auth/providers/auth_providers.dart'; +import 'package:eigen_flutter/features/game/providers/game_providers.dart'; +import 'package:eigen_flutter/features/social/providers/social_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +void main() { + testWidgets('medium rail scrolls instead of overflowing a short window', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(700, 300)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final router = GoRouter( + routes: [ + StatefulShellRoute.indexedStack( + builder: (_, _, navigationShell) => + ShellScaffold(navigationShell: navigationShell), + branches: [ + _branch('/', 'Home body'), + _branch('/lobby', 'Lobby body'), + _branch('/history', 'History body'), + _branch('/social', 'Social body'), + _branch('/about', 'About body'), + _branch('/settings', 'Settings body'), + ], + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + isOfflineProvider.overrideWithValue(false), + isAnonymousProvider.overrideWithValue(false), + soloPlayAvailableProvider.overrideWithValue(false), + incomingRequestsProvider.overrideWith( + (_) async => const <FriendRequest>[], + ), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pump(); + + final rail = tester.widget<NavigationRail>(find.byType(NavigationRail)); + expect(rail.extended, isFalse); + expect(rail.scrollable, isTrue); + expect(rail.leadingAtTop, isFalse); + expect(tester.takeException(), isNull); + }); +} + +StatefulShellBranch _branch(String path, String label) { + return StatefulShellBranch( + routes: [GoRoute(path: path, builder: (_, _) => Text(label))], + ); +} diff --git a/test/core/theme/app_semantic_colors_test.dart b/test/core/theme/app_semantic_colors_test.dart new file mode 100644 index 0000000..5cda7b3 --- /dev/null +++ b/test/core/theme/app_semantic_colors_test.dart @@ -0,0 +1,82 @@ +import 'dart:math' as math; + +import 'package:checks/checks.dart'; +import 'package:eigen_flutter/core/theme/app_semantic_colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final variants = <String, AppSemanticColors>{ + 'light': AppSemanticColors.forBrightness(Brightness.light), + 'dark': AppSemanticColors.forBrightness(Brightness.dark), + 'high-contrast light': AppSemanticColors.forBrightness( + Brightness.light, + highContrast: true, + ), + 'high-contrast dark': AppSemanticColors.forBrightness( + Brightness.dark, + highContrast: true, + ), + }; + + for (final entry in variants.entries) { + test('${entry.key} pairs meet normal text contrast', () { + final colors = entry.value; + final pairs = <(Color, Color)>[ + (colors.success, colors.onSuccess), + (colors.successContainer, colors.onSuccessContainer), + (colors.warning, colors.onWarning), + (colors.warningContainer, colors.onWarningContainer), + (colors.info, colors.onInfo), + (colors.infoContainer, colors.onInfoContainer), + ]; + + for (final (background, foreground) in pairs) { + check(_contrastRatio(background, foreground)).isGreaterThan(4.49); + } + }); + } + + test('high-contrast palettes strengthen the light semantic containers', () { + final normal = variants['light']!; + final highContrast = variants['high-contrast light']!; + + check( + _contrastRatio( + highContrast.successContainer, + highContrast.onSuccessContainer, + ), + ).isGreaterThan( + _contrastRatio(normal.successContainer, normal.onSuccessContainer), + ); + check( + _contrastRatio( + highContrast.warningContainer, + highContrast.onWarningContainer, + ), + ).isGreaterThan( + _contrastRatio(normal.warningContainer, normal.onWarningContainer), + ); + check( + _contrastRatio(highContrast.infoContainer, highContrast.onInfoContainer), + ).isGreaterThan( + _contrastRatio(normal.infoContainer, normal.onInfoContainer), + ); + }); + + test('copyWith and lerp retain the ThemeExtension contract', () { + final light = variants['light']!; + final dark = variants['dark']!; + + check(light.copyWith(success: Colors.pink).success).equals(Colors.pink); + check(light.lerp(dark, 0).success).equals(light.success); + check(light.lerp(dark, 1).success).equals(dark.success); + }); +} + +double _contrastRatio(Color a, Color b) { + final aLuminance = a.computeLuminance(); + final bLuminance = b.computeLuminance(); + return (math.max(aLuminance, bLuminance) + 0.05) / + (math.min(aLuminance, bLuminance) + 0.05); +} diff --git a/test/core/theme/app_theme_test.dart b/test/core/theme/app_theme_test.dart index 883a489..0f2f476 100644 --- a/test/core/theme/app_theme_test.dart +++ b/test/core/theme/app_theme_test.dart @@ -1,4 +1,5 @@ import 'package:checks/checks.dart'; +import 'package:eigen_flutter/core/theme/app_semantic_colors.dart'; import 'package:eigen_flutter/core/theme/app_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -68,4 +69,72 @@ void main() { AppTheme.dark(Colors.teal).colorScheme.brightness, ).equals(Brightness.dark); }); + + test('builds and caches distinct high-contrast themes', () { + final regular = AppTheme.light(Colors.teal); + final highContrast = AppTheme.highContrastLight(Colors.teal); + + check(identical(regular, highContrast)).isFalse(); + check( + identical(highContrast, AppTheme.highContrastLight(Colors.teal)), + ).isTrue(); + check(highContrast.colorScheme.brightness).equals(Brightness.light); + check( + highContrast.colorScheme.primary, + ).not((color) => color.equals(regular.colorScheme.primary)); + }); + + test('installs the matching semantic extension on every theme', () { + final light = AppTheme.light(Colors.teal); + final dark = AppTheme.dark(Colors.teal); + final highContrastLight = AppTheme.highContrastLight(Colors.teal); + final highContrastDark = AppTheme.highContrastDark(Colors.teal); + + check( + light.extension<AppSemanticColors>()!.success, + ).equals(Colors.green.shade800); + check( + dark.extension<AppSemanticColors>()!.success, + ).equals(Colors.green.shade300); + check( + highContrastLight.extension<AppSemanticColors>()!.success, + ).equals(Colors.green.shade900); + check( + highContrastDark.extension<AppSemanticColors>()!.success, + ).equals(Colors.green.shade100); + }); + + test('semantic meanings do not change with the brand seed', () { + final teal = AppTheme.light(Colors.teal).extension<AppSemanticColors>()!; + final red = AppTheme.light(Colors.red).extension<AppSemanticColors>()!; + + check(teal.success).equals(red.success); + check(teal.warning).equals(red.warning); + check(teal.info).equals(red.info); + }); + + testWidgets('floating snackbars remain usable on compact windows', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final messengerKey = GlobalKey<ScaffoldMessengerState>(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(Colors.teal), + scaffoldMessengerKey: messengerKey, + home: const Scaffold(body: SizedBox.expand()), + ), + ); + messengerKey.currentState!.showSnackBar( + const SnackBar(content: Text('Saved')), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(SnackBar)).width, lessThanOrEqualTo(320)); + }); } diff --git a/test/features/auth/branded_google_button_test.dart b/test/features/auth/branded_google_button_test.dart new file mode 100644 index 0000000..d60934f --- /dev/null +++ b/test/features/auth/branded_google_button_test.dart @@ -0,0 +1,78 @@ +import 'dart:ui' show PointerDeviceKind; + +import 'package:eigen_flutter/features/auth/presentation/widgets/branded_google_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('provides branded interaction and a 48dp target', (tester) async { + var presses = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center(child: BrandedGoogleButton(onPressed: () => presses++)), + ), + ), + ); + + final semantics = tester + .getSemantics(find.byType(BrandedGoogleButton)) + .getSemanticsData(); + expect(semantics.label, 'Sign in with Google'); + expect(semantics.hasAction(SemanticsAction.tap), isTrue); + expect( + tester.getSize(find.byType(InkWell)).height, + greaterThanOrEqualTo(48), + ); + + await tester.tap(find.byType(BrandedGoogleButton)); + expect(presses, 1); + }); + + testWidgets('announces and displays progress while loading', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: BrandedGoogleButton(onPressed: null, isLoading: true), + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + final semantics = tester + .getSemantics(find.byType(BrandedGoogleButton)) + .getSemanticsData(); + expect(semantics.label, 'Signing in with Google'); + expect(semantics.hasAction(SemanticsAction.tap), isFalse); + }); + + testWidgets('composites interaction ink above the brand artwork', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(hoverColor: Colors.red), + home: Scaffold( + body: Center(child: BrandedGoogleButton(onPressed: () {})), + ), + ), + ); + await tester.pumpAndSettle(); + + // Ink.image paints the opaque artwork into the Material's ink layer. The + // same Material therefore composites InkWell hover/pressed ink above it. + expect(find.byType(Ink), findsOneWidget); + expect(find.byType(Image), findsNothing); + final ink = tester.widget<Ink>(find.byType(Ink)); + expect((ink.decoration! as BoxDecoration).image, isNotNull); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(find.byType(BrandedGoogleButton))); + await tester.pump(const Duration(milliseconds: 200)); + + expect(tester.takeException(), isNull); + await mouse.removePointer(); + }); +} diff --git a/test/features/auth/login_screen_adaptive_test.dart b/test/features/auth/login_screen_adaptive_test.dart new file mode 100644 index 0000000..83e1778 --- /dev/null +++ b/test/features/auth/login_screen_adaptive_test.dart @@ -0,0 +1,61 @@ +import 'package:eigen_flutter/core/config/app_config.dart'; +import 'package:eigen_flutter/core/theme/app_theme.dart'; +import 'package:eigen_flutter/features/auth/presentation/screens/login_screen.dart'; +import 'package:eigen_flutter/features/auth/providers/auth_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'stays scrollable with usable legal links on compact large text', + (tester) async { + tester.view.physicalSize = const Size(360, 800); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + appConfigProvider.overrideWithValue( + const AppConfig( + branding: Branding( + appName: 'Rock Paper Scissors', + seedColor: Colors.teal, + ), + engine: EngineConfig( + apiBaseUrl: 'https://example.test', + appHost: 'example.test', + googleWebClientId: 'test-client', + firebaseVapidKey: 'test-vapid-key', + ), + ), + ), + authControllerProvider.overrideWithValue( + const AsyncData<void>(null), + ), + ], + child: MaterialApp( + theme: AppTheme.light(Colors.teal), + home: const LoginScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(SingleChildScrollView), findsOneWidget); + for (final label in ['Terms of Service', 'Privacy Policy']) { + final button = find.ancestor( + of: find.text(label), + matching: find.byType(TextButton), + ); + expect(button, findsOneWidget); + expect(tester.getSize(button).height, greaterThanOrEqualTo(48)); + } + }, + ); +} diff --git a/test/features/game/presentation/game_ui_test.dart b/test/features/game/presentation/game_ui_test.dart new file mode 100644 index 0000000..75e5750 --- /dev/null +++ b/test/features/game/presentation/game_ui_test.dart @@ -0,0 +1,76 @@ +import 'package:checks/checks.dart'; +import 'package:eigen_api/eigen_api.dart'; +import 'package:eigen_flutter/core/theme/app_semantic_colors.dart'; +import 'package:eigen_flutter/features/game/presentation/extensions/game_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final colorScheme = ColorScheme.fromSeed(seedColor: Colors.purple); + final semanticColors = AppSemanticColors.forBrightness(Brightness.light); + + test('game statuses use semantic roles instead of brand roles', () { + check( + GameStatus.waiting.color(colorScheme, semanticColors: semanticColors), + ).equals(semanticColors.warning); + check( + GameStatus.ready.color(colorScheme, semanticColors: semanticColors), + ).equals(semanticColors.info); + check( + GameStatus.active.color(colorScheme, semanticColors: semanticColors), + ).equals(semanticColors.success); + check( + GameStatus.aborted.color(colorScheme, semanticColors: semanticColors), + ).equals(colorScheme.error); + check( + GameStatus.finished.color(colorScheme, semanticColors: semanticColors), + ).equals(colorScheme.onSurfaceVariant); + }); + + test('game status containers return their paired foregrounds', () { + check( + GameStatus.waiting.containerColor( + colorScheme, + semanticColors: semanticColors, + ), + ).equals(semanticColors.warningContainer); + check( + GameStatus.waiting.onContainerColor( + colorScheme, + semanticColors: semanticColors, + ), + ).equals(semanticColors.onWarningContainer); + check( + GameStatus.aborted.containerColor( + colorScheme, + semanticColors: semanticColors, + ), + ).equals(colorScheme.errorContainer); + check( + GameStatus.aborted.onContainerColor( + colorScheme, + semanticColors: semanticColors, + ), + ).equals(colorScheme.onErrorContainer); + }); + + test('outcomes use success, info, error, and neutral roles', () { + check( + OutcomeResultEnum.win.color(colorScheme, semanticColors: semanticColors), + ).equals(semanticColors.success); + check( + OutcomeResultEnum.draw.color(colorScheme, semanticColors: semanticColors), + ).equals(semanticColors.info); + check( + OutcomeResultEnum.loss.color(colorScheme, semanticColors: semanticColors), + ).equals(colorScheme.error); + + final OutcomeResultEnum? aborted = null; + check( + aborted.color(colorScheme, semanticColors: semanticColors), + ).equals(colorScheme.onSurfaceVariant); + check( + aborted.containerColor(colorScheme, semanticColors: semanticColors), + ).equals(colorScheme.surfaceContainerHighest); + }); +} diff --git a/test/features/game/presentation/new_game_dialog_test.dart b/test/features/game/presentation/new_game_dialog_test.dart new file mode 100644 index 0000000..bc5704d --- /dev/null +++ b/test/features/game/presentation/new_game_dialog_test.dart @@ -0,0 +1,52 @@ +import 'package:eigen_flutter/features/auth/providers/auth_providers.dart'; +import 'package:eigen_flutter/features/game/presentation/widgets/new_game_dialog.dart'; +import 'package:eigen_flutter/features/game/providers/game_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../helpers/fakes.dart'; + +void main() { + testWidgets('remains scrollable on a compact, large-text viewport', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentGameModuleProvider.overrideWithValue(const SampleModule()), + isAnonymousProvider.overrideWithValue(false), + ], + child: MaterialApp( + home: MediaQuery.withClampedTextScaling( + minScaleFactor: 2, + maxScaleFactor: 2, + child: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showDialog<void>( + context: context, + builder: (_) => const NewGameDialog(), + ), + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('New Game'), findsOneWidget); + expect(find.byType(SingleChildScrollView), findsWidgets); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/features/rating/presentation/rating_ui_test.dart b/test/features/rating/presentation/rating_ui_test.dart new file mode 100644 index 0000000..de1f5a8 --- /dev/null +++ b/test/features/rating/presentation/rating_ui_test.dart @@ -0,0 +1,35 @@ +import 'package:checks/checks.dart'; +import 'package:eigen_api/eigen_api.dart'; +import 'package:eigen_flutter/core/theme/app_semantic_colors.dart'; +import 'package:eigen_flutter/features/rating/presentation/extensions/rating_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final colorScheme = ColorScheme.fromSeed(seedColor: Colors.purple); + final semanticColors = AppSemanticColors.forBrightness(Brightness.light); + + test('rating gains use success while losses and no-change stay standard', () { + check( + _delta(12).color(colorScheme, semanticColors: semanticColors), + ).equals(semanticColors.success); + check( + _delta(-12).color(colorScheme, semanticColors: semanticColors), + ).equals(colorScheme.error); + check( + _delta(0).color(colorScheme, semanticColors: semanticColors), + ).equals(colorScheme.onSurfaceVariant); + }); +} + +RatingDelta _delta(int displayChange) => RatingDelta( + identity: RatingIdentity(userId: 'player', botId: null), + pool: 'rapid', + muBefore: 25, + sigmaBefore: 8, + displayBefore: 1000, + muAfter: 26, + sigmaAfter: 7, + displayAfter: 1000 + displayChange, + displayChange: displayChange, +); diff --git a/test/features/social/presentation/friend_buttons_accessibility_test.dart b/test/features/social/presentation/friend_buttons_accessibility_test.dart new file mode 100644 index 0000000..57275ef --- /dev/null +++ b/test/features/social/presentation/friend_buttons_accessibility_test.dart @@ -0,0 +1,35 @@ +import 'package:eigen_flutter/features/social/presentation/widgets/friend_buttons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('compact friend actions are labelled Material tap targets', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + home: Scaffold( + body: Wrap( + children: [ + AcceptRequestButton(playerId: 'accept', compact: true), + DeclineRequestButton(playerId: 'decline', compact: true), + RemoveFriendButton(playerId: 'remove', compact: true), + ], + ), + ), + ), + ), + ); + + expect(find.byTooltip('Accept friend request'), findsOneWidget); + expect(find.byTooltip('Decline friend request'), findsOneWidget); + expect(find.byTooltip('Remove friend'), findsOneWidget); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + semantics.dispose(); + }); +} diff --git a/test/features/social/presentation/player_profile_panel_test.dart b/test/features/social/presentation/player_profile_panel_test.dart new file mode 100644 index 0000000..cb141c3 --- /dev/null +++ b/test/features/social/presentation/player_profile_panel_test.dart @@ -0,0 +1,190 @@ +import 'package:eigen_api/eigen_api.dart'; +import 'package:eigen_flutter/core/config/app_config.dart'; +import 'package:eigen_flutter/core/theme/app_theme.dart'; +import 'package:eigen_flutter/features/game/providers/game_providers.dart'; +import 'package:eigen_flutter/features/rating/providers/rating_providers.dart'; +import 'package:eigen_flutter/features/social/presentation/widgets/player_profile_sheet.dart'; +import 'package:eigen_flutter/shared/providers/player_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +const _playerId = 'player-1'; +const _gameId = 'game-12345678'; + +const _config = AppConfig( + branding: Branding(appName: 'Test'), + engine: EngineConfig( + apiBaseUrl: 'https://example.test', + googleWebClientId: 'client', + firebaseVapidKey: 'vapid', + ), +); + +final _game = GameSummary( + id: _gameId, + createdBy: null, + status: GameStatus.finished, + access: GameAccess.public, + schemaVersion: 1, + config: const <String, dynamic>{}, + turnSeconds: null, + budgetSeconds: null, + incrementSeconds: null, + rated: false, + ratingPool: null, + minPlayers: 2, + maxPlayers: 2, + shortCode: 'ABC123', + pendingPlayers: null, + turnDeadline: null, + outcomes: null, + finishedAt: 1, + createdAt: 0, + updatedAt: 1, + participants: const [], +); + +class _FakePlayerCache extends PlayerInfoCache { + @override + Future<Player> build({required String id}) async => Player( + id: id, + username: 'tester', + displayName: 'Test Player', + avatarUrl: null, + isAnonymous: false, + ); +} + +Future<void> _pump(WidgetTester tester, GoRouter router) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + appConfigProvider.overrideWithValue(_config), + playerInfoCacheProvider( + id: _playerId, + ).overrideWith(_FakePlayerCache.new), + playerRatingsProvider(_playerId).overrideWith((ref) async => const []), + playerPublicFinishedGamesProvider( + playerId: _playerId, + ).overrideWith((ref) async => [_game]), + ], + child: MaterialApp.router( + theme: AppTheme.light(Colors.teal), + routerConfig: router, + ), + ), + ); + await tester.pumpAndSettle(); +} + +Widget _replayPage(GoRouterState state) => Scaffold( + body: Center(child: Text('Replay ${state.pathParameters['gameId']}')), +); + +void main() { + testWidgets('embedded replay preserves the containing profile route', ( + tester, + ) async { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold( + body: Center( + child: FilledButton( + onPressed: () => context.pushNamed('profile'), + child: const Text('Open profile'), + ), + ), + ), + ), + GoRoute( + path: '/profile', + name: 'profile', + builder: (context, state) => const Scaffold( + body: Column( + children: [ + Text('Embedded profile'), + Expanded( + child: PlayerProfilePanel( + playerId: _playerId, + type: SeatTypeEnum.bot, + ), + ), + ], + ), + ), + ), + GoRoute( + path: '/replay/:gameId', + name: 'replay', + builder: (context, state) => _replayPage(state), + ), + ], + ); + addTearDown(router.dispose); + await _pump(tester, router); + + await tester.tap(find.text('Open profile')); + await tester.pumpAndSettle(); + final gameRow = find.text('Game #game-123'); + await tester.ensureVisible(gameRow); + await tester.tap(gameRow); + await tester.pumpAndSettle(); + expect(find.text('Replay $_gameId'), findsOneWidget); + + router.pop(); + await tester.pumpAndSettle(); + expect(find.text('Embedded profile'), findsOneWidget); + }); + + testWidgets('modal replay dismisses the sheet before navigating', ( + tester, + ) async { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold( + body: Center( + child: FilledButton( + onPressed: () => PlayerProfileSheet.show( + context, + playerId: _playerId, + type: SeatTypeEnum.bot, + ), + child: const Text('Open sheet'), + ), + ), + ), + ), + GoRoute( + path: '/replay/:gameId', + name: 'replay', + builder: (context, state) => _replayPage(state), + ), + ], + ); + addTearDown(router.dispose); + await _pump(tester, router); + + await tester.tap(find.text('Open sheet')); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsOneWidget); + + final gameRow = find.text('Game #game-123'); + for (var i = 0; i < 3 && gameRow.evaluate().isEmpty; i++) { + await tester.drag(find.byType(CustomScrollView), const Offset(0, -240)); + await tester.pumpAndSettle(); + } + expect(gameRow, findsOneWidget); + await tester.ensureVisible(gameRow); + await tester.tap(gameRow); + await tester.pumpAndSettle(); + + expect(find.byType(BottomSheet), findsNothing); + expect(find.text('Replay $_gameId'), findsOneWidget); + }); +} diff --git a/test/features/social/presentation/social_url_tabs_test.dart b/test/features/social/presentation/social_url_tabs_test.dart new file mode 100644 index 0000000..390ba2d --- /dev/null +++ b/test/features/social/presentation/social_url_tabs_test.dart @@ -0,0 +1,162 @@ +import 'package:eigen_api/eigen_api.dart' show Friend, Player; +import 'package:eigen_flutter/core/config/app_config.dart'; +import 'package:eigen_flutter/features/social/presentation/social_screen.dart'; +import 'package:eigen_flutter/features/social/providers/social_providers.dart'; +import 'package:eigen_flutter/shared/providers/player_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +class _EmptyFriends extends Friends { + _EmptyFriends(this.values); + + final List<Friend> values; + + @override + Future<List<Friend>> build() async => values; +} + +class _AdaPlayer extends PlayerInfoCache { + @override + Future<Player> build({required String id}) async => Player( + id: id, + username: 'ada', + displayName: 'Ada', + avatarUrl: null, + isAnonymous: false, + ); +} + +void main() { + const config = AppConfig( + branding: Branding(appName: 'Test', seedColor: Colors.teal), + engine: EngineConfig( + apiBaseUrl: 'https://example.test', + googleWebClientId: 'client', + firebaseVapidKey: 'key', + ), + ); + late GoRouter router; + + Future<void> pumpSocial( + WidgetTester tester, { + String initialLocation = '/social', + List<Friend> friends = const [], + }) async { + router = GoRouter( + initialLocation: initialLocation, + routes: [ + GoRoute( + path: '/social', + builder: (context, state) => const Scaffold(body: SocialScreen()), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + appConfigProvider.overrideWithValue(config), + friendsProvider.overrideWith(() => _EmptyFriends(friends)), + incomingRequestsProvider.overrideWith((ref) async => const []), + playerInfoCacheProvider(id: 'ada-id').overrideWith(_AdaPlayer.new), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + } + + int selectedTab(WidgetTester tester) => + tester.widget<TabBar>(find.byType(TabBar)).controller!.index; + + testWidgets('reads the initial tab from the URL and follows URL changes', ( + tester, + ) async { + await pumpSocial(tester, initialLocation: '/social?tab=requests'); + expect(selectedTab(tester), 1); + + router.go('/social?tab=add'); + await tester.pumpAndSettle(); + expect(selectedTab(tester), 2); + + router.go('/social'); + await tester.pumpAndSettle(); + expect(selectedTab(tester), 0); + }); + + testWidgets('writes clicks and swipes to canonical tab URLs', (tester) async { + final routeUpdates = <MethodCall>[]; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.navigation, + (call) async { + if (call.method == 'routeInformationUpdated') routeUpdates.add(call); + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.navigation, + null, + ), + ); + + await pumpSocial(tester); + routeUpdates.clear(); + + await tester.tap(find.widgetWithText(Tab, 'Add Friend')); + await tester.pumpAndSettle(); + expect( + router.routeInformationProvider.value.uri.toString(), + '/social?tab=add', + ); + expect(selectedTab(tester), 2); + expect(routeUpdates.last.arguments, containsPair('replace', false)); + + router.go('/social'); + await tester.pumpAndSettle(); + routeUpdates.clear(); + await tester.drag(find.byType(TabBarView), const Offset(-500, 0)); + await tester.pumpAndSettle(); + + expect(selectedTab(tester), 1); + expect( + router.routeInformationProvider.value.uri.toString(), + '/social?tab=requests', + ); + expect(routeUpdates.last.arguments, containsPair('replace', true)); + }); + + testWidgets('marks the selected player in the expanded master list', ( + tester, + ) async { + tester.view + ..physicalSize = const Size(1200, 800) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await pumpSocial( + tester, + friends: [ + Friend( + username: 'ada', + displayName: 'Ada', + avatarUrl: null, + isAnonymous: false, + userId: 'ada-id', + since: 1, + ), + ], + ); + + final ada = find.widgetWithText(ListTile, 'Ada'); + expect(tester.widget<ListTile>(ada).selected, isFalse); + + await tester.tap(ada); + await tester.pump(); + expect(tester.widget<ListTile>(ada).selected, isTrue); + }); +} diff --git a/test/shared/widgets/adaptive_single_choice_test.dart b/test/shared/widgets/adaptive_single_choice_test.dart new file mode 100644 index 0000000..a7b35c7 --- /dev/null +++ b/test/shared/widgets/adaptive_single_choice_test.dart @@ -0,0 +1,106 @@ +import 'package:eigen_flutter/shared/widgets/adaptive_single_choice.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const choices = [ + AdaptiveChoice(value: 'one', label: 'One'), + AdaptiveChoice(value: 'two', label: 'Two'), + AdaptiveChoice(value: 'three', label: 'Three'), + ]; + + Future<void> pumpChoice( + WidgetTester tester, { + required double width, + required ValueChanged<String> onChanged, + }) { + return tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: width, + child: AdaptiveSingleChoice<String>( + choices: choices, + value: 'one', + onChanged: onChanged, + ), + ), + ), + ), + ), + ); + } + + testWidgets('shows visible segments when the options fit', (tester) async { + String? selected; + await pumpChoice( + tester, + width: 360, + onChanged: (value) => selected = value, + ); + + expect(find.byType(SegmentedButton<String>), findsOneWidget); + expect(find.byType(DropdownMenu<String>), findsNothing); + + await tester.tap(find.text('Two')); + await tester.pump(); + expect(selected, 'two'); + }); + + testWidgets('uses a Material 3 menu before segments overflow', ( + tester, + ) async { + await pumpChoice(tester, width: 180, onChanged: (_) {}); + + expect(find.byType(SegmentedButton<String>), findsNothing); + expect(find.byType(DropdownMenu<String>), findsOneWidget); + }); + + testWidgets('can be hosted in a standard Material dialog', (tester) async { + tester.view.physicalSize = const Size(320, 480); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => showDialog<void>( + context: context, + builder: (_) => AlertDialog( + title: const Text('Choose'), + content: SizedBox( + width: 480, + child: SingleChildScrollView( + child: AdaptiveSingleChoice<String>( + choices: choices, + value: 'one', + onChanged: (_) {}, + ), + ), + ), + ), + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Choose'), findsOneWidget); + expect( + tester.getRect(find.byType(AlertDialog)).right, + lessThanOrEqualTo(320), + ); + }); +} diff --git a/test/shared/widgets/made_by_credit_test.dart b/test/shared/widgets/made_by_credit_test.dart index e587f7d..3b78f0d 100644 --- a/test/shared/widgets/made_by_credit_test.dart +++ b/test/shared/widgets/made_by_credit_test.dart @@ -1,9 +1,12 @@ -import 'package:flutter/gestures.dart'; +import 'dart:ui' show SemanticsAction; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:eigen_flutter/core/config/app_config.dart'; import 'package:eigen_flutter/shared/widgets/made_by_credit.dart'; +import 'package:url_launcher/link.dart'; /// The line as the app ships it, and as the game's own website renders it. AppConfig _config(String credit) => AppConfig( @@ -15,19 +18,6 @@ AppConfig _config(String credit) => AppConfig( ), ); -/// The spans of the rendered line, paired with whether each is tappable. -List<(String, bool)> _spans(WidgetTester tester) { - final text = tester.widget<Text>(find.byType(Text)); - final spans = <(String, bool)>[]; - text.textSpan?.visitChildren((span) { - if (span is TextSpan && span.text != null) { - spans.add((span.text!, span.recognizer is TapGestureRecognizer)); - } - return true; - }); - return spans; -} - Future<void> _pump(WidgetTester tester, String credit) => tester.pumpWidget( ProviderScope( overrides: [appConfigProvider.overrideWithValue(_config(credit))], @@ -41,24 +31,38 @@ void main() { ) async { await _pump(tester, 'Built with EigenInteractive'); - // "Build with" is prose and points nowhere; only the name is tappable. - expect(_spans(tester), [ - ('Built with ', false), - ('EigenInteractive', true), - ]); + // "Built with" remains prose; only the name is represented by a link. + expect(find.text('Built with '), findsOneWidget); + expect(find.byType(Link), findsOneWidget); }); - testWidgets('marks the link by colour rather than an underline', ( + testWidgets('underlines the link and exposes link semantics and focus', ( tester, ) async { + final semantics = tester.ensureSemantics(); await _pump(tester, 'Built with EigenInteractive'); final context = tester.element(find.byType(MadeByCredit)); final primary = Theme.of(context).colorScheme.primary; - final text = tester.widget<Text>(find.byType(Text)); - final brand = (text.textSpan! as TextSpan).children![1] as TextSpan; + final brand = tester.widget<Text>(find.text('EigenInteractive')); expect(brand.style?.color, primary); - expect(brand.style?.decoration, isNot(TextDecoration.underline)); + expect(brand.style?.decoration, TextDecoration.underline); + + final node = tester.getSemantics(find.bySemanticsLabel('EigenInteractive')); + final data = node.getSemanticsData(); + expect(data.flagsCollection.isLink, isTrue); + expect(data.hasAction(SemanticsAction.tap), isTrue); + expect(data.linkUrl, Uri.parse('https://eigeninteractive.com')); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + final focusedContext = tester.binding.focusManager.primaryFocus?.context; + expect(focusedContext, isNotNull); + expect( + focusedContext!.findAncestorWidgetOfExactType<TextButton>(), + isNotNull, + ); + semantics.dispose(); }); testWidgets('leaves a credit that never names the engine as plain text', ( @@ -69,6 +73,6 @@ void main() { await _pump(tester, 'Made by tester'); expect(find.text('Made by tester'), findsOneWidget); - expect(_spans(tester), isEmpty); + expect(find.byType(Link), findsNothing); }); } diff --git a/test/shared/widgets/player_avatar_test.dart b/test/shared/widgets/player_avatar_test.dart new file mode 100644 index 0000000..8967f4a --- /dev/null +++ b/test/shared/widgets/player_avatar_test.dart @@ -0,0 +1,134 @@ +import 'dart:ui' show PointerDeviceKind, SemanticsAction, Tristate; + +import 'package:eigen_flutter/core/config/app_config.dart'; +import 'package:eigen_flutter/shared/widgets/player_avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _config = AppConfig( + branding: Branding(appName: 'Test App'), + engine: EngineConfig( + apiBaseUrl: 'https://example.test', + googleWebClientId: 'client', + firebaseVapidKey: 'vapid', + ), +); + +Future<void> _pump(WidgetTester tester, Widget child, {ThemeData? theme}) => + tester.pumpWidget( + ProviderScope( + overrides: [appConfigProvider.overrideWithValue(_config)], + child: MaterialApp( + theme: theme, + home: Scaffold(body: Center(child: child)), + ), + ), + ); + +void main() { + testWidgets('interactive avatar is labelled, keyboard operable, and 48px', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + var taps = 0; + + await _pump( + tester, + PlayerAvatar( + avatarUrl: null, + semanticLabel: "Open Ada's profile", + onTap: () => taps++, + ), + ); + + expect(find.byTooltip("Open Ada's profile"), findsOneWidget); + final node = tester.getSemantics( + find.bySemanticsLabel("Open Ada's profile"), + ); + final data = node.getSemanticsData(); + expect(data.flagsCollection.isButton, isTrue); + expect(data.hasAction(SemanticsAction.tap), isTrue); + + final hitTarget = tester.getSize(find.byType(InkResponse)); + expect(hitTarget.width, greaterThanOrEqualTo(48)); + expect(hitTarget.height, greaterThanOrEqualTo(48)); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect( + tester + .getSemantics(find.bySemanticsLabel("Open Ada's profile")) + .getSemanticsData() + .flagsCollection + .isFocused, + Tristate.isTrue, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(taps, 1); + semantics.dispose(); + }); + + testWidgets('non-interactive avatar can be exposed as an image', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + + await _pump( + tester, + const PlayerAvatar(avatarUrl: null, semanticLabel: "Ada's avatar"), + ); + + final data = tester + .getSemantics(find.bySemanticsLabel("Ada's avatar")) + .getSemanticsData(); + expect(data.flagsCollection.isImage, isTrue); + expect(data.hasAction(SemanticsAction.tap), isFalse); + semantics.dispose(); + }); + + testWidgets('composites interaction ink above the opaque avatar', ( + tester, + ) async { + await _pump( + tester, + PlayerAvatar(avatarUrl: null, onTap: () {}), + theme: ThemeData(hoverColor: Colors.red), + ); + + // The opaque avatar is the first Stack child. A separate transparent + // Material and InkResponse are painted after it, so hover/pressed ink cannot + // be hidden by the CircleAvatar or a network image. + final stack = tester.widget<Stack>( + find.descendant( + of: find.byType(PlayerAvatar), + matching: find.byType(Stack), + ), + ); + expect(stack.children.first, isA<ExcludeSemantics>()); + expect(stack.children.last, isA<Positioned>()); + final overlay = find.descendant( + of: find.byWidget(stack.children.last), + matching: find.byType(Material), + ); + final material = tester.widget<Material>(overlay); + expect(material.type, MaterialType.transparency); + expect(material.shape, isA<CircleBorder>()); + expect( + find.descendant(of: overlay, matching: find.byType(InkResponse)), + findsOneWidget, + ); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(find.byType(PlayerAvatar))); + await tester.pump(const Duration(milliseconds: 200)); + + expect(tester.takeException(), isNull); + await mouse.removePointer(); + }); +} diff --git a/test/shared/widgets/status_banner_test.dart b/test/shared/widgets/status_banner_test.dart new file mode 100644 index 0000000..3e77394 --- /dev/null +++ b/test/shared/widgets/status_banner_test.dart @@ -0,0 +1,33 @@ +import 'dart:ui' show SemanticsRole; + +import 'package:eigen_flutter/shared/widgets/status_banner.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('exposes system status as one semantic status node', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StatusBanner( + leading: Icon(Icons.cloud_off), + label: 'Connection lost', + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + ), + ), + ); + + final node = tester.getSemantics(find.bySemanticsLabel('Connection lost')); + final data = node.getSemanticsData(); + expect(data.label, 'Connection lost'); + expect(data.role, SemanticsRole.status); + expect(find.bySemanticsLabel('Connection lost'), findsOneWidget); + semantics.dispose(); + }); +} From cc97a1f63b0c204748b08f857b4a3fee96acdc12 Mon Sep 17 00:00:00 2001 From: Seenu K <iitm.seenuk@gmail.com> Date: Wed, 12 Aug 2026 18:49:56 +0530 Subject: [PATCH 2/2] Remove legacy shims --- .../providers/navigation_providers.g.dart | 2 +- .../game/presentation/extensions/game_ui.dart | 100 ++++++--------- .../presentation/screens/game_screen.dart | 1 + .../screens/game_screen_pre_game.dart | 4 +- .../widgets/play_vs_bot_dialog.dart | 6 +- .../game/providers/game_providers.dart | 3 +- .../game/providers/game_providers.g.dart | 2 +- .../game/utils/bot_compatibility.dart | 11 ++ .../presentation/extensions/rating_ui.dart | 9 +- .../social/presentation/social_screen.dart | 4 +- .../widgets/player_profile_sheet.dart | 65 ++++------ .../presentation/play_vs_bot_dialog_test.dart | 119 ++++++++++++++++++ .../player_profile_panel_test.dart | 2 +- 13 files changed, 211 insertions(+), 117 deletions(-) create mode 100644 lib/features/game/utils/bot_compatibility.dart create mode 100644 test/features/game/presentation/play_vs_bot_dialog_test.dart diff --git a/lib/core/navigation/providers/navigation_providers.g.dart b/lib/core/navigation/providers/navigation_providers.g.dart index 34d036e..d934356 100644 --- a/lib/core/navigation/providers/navigation_providers.g.dart +++ b/lib/core/navigation/providers/navigation_providers.g.dart @@ -61,4 +61,4 @@ final class GoRouterProvider } } -String _$goRouterHash() => r'83ecafd33c73e3c9e621e0b0fc40a496d1a5697f'; +String _$goRouterHash() => r'f38bdaf2d142477236ae3cada0b9e0d7889df61f'; diff --git a/lib/features/game/presentation/extensions/game_ui.dart b/lib/features/game/presentation/extensions/game_ui.dart index 761bd5d..eaf568a 100644 --- a/lib/features/game/presentation/extensions/game_ui.dart +++ b/lib/features/game/presentation/extensions/game_ui.dart @@ -7,32 +7,27 @@ extension GameStatusUI on GameStatus { /// Returns the semantic foreground associated with this status. /// /// Pass the extension from the active theme so high-contrast mode is - /// preserved. The brightness-derived fallback keeps existing callers - /// brand-independent while they migrate. - Color color(ColorScheme colorScheme, {AppSemanticColors? semanticColors}) => - switch (this) { - GameStatus.waiting => _semantic(colorScheme, semanticColors).warning, - GameStatus.ready => _semantic(colorScheme, semanticColors).info, - GameStatus.active => _semantic(colorScheme, semanticColors).success, - GameStatus.finished => colorScheme.onSurfaceVariant, - GameStatus.aborted => colorScheme.error, - GameStatus.unknownDefaultOpenApi => colorScheme.onSurfaceVariant, - }; + /// preserved. + Color color( + ColorScheme colorScheme, { + required AppSemanticColors semanticColors, + }) => switch (this) { + GameStatus.waiting => semanticColors.warning, + GameStatus.ready => semanticColors.info, + GameStatus.active => semanticColors.success, + GameStatus.finished => colorScheme.onSurfaceVariant, + GameStatus.aborted => colorScheme.error, + GameStatus.unknownDefaultOpenApi => colorScheme.onSurfaceVariant, + }; /// Returns the semantic container associated with this status. Color containerColor( ColorScheme colorScheme, { - AppSemanticColors? semanticColors, + required AppSemanticColors semanticColors, }) => switch (this) { - GameStatus.waiting => _semantic( - colorScheme, - semanticColors, - ).warningContainer, - GameStatus.ready => _semantic(colorScheme, semanticColors).infoContainer, - GameStatus.active => _semantic( - colorScheme, - semanticColors, - ).successContainer, + GameStatus.waiting => semanticColors.warningContainer, + GameStatus.ready => semanticColors.infoContainer, + GameStatus.active => semanticColors.successContainer, GameStatus.finished || GameStatus.unknownDefaultOpenApi => colorScheme.surfaceContainerHighest, GameStatus.aborted => colorScheme.errorContainer, @@ -41,17 +36,11 @@ extension GameStatusUI on GameStatus { /// Returns the paired foreground for [containerColor]. Color onContainerColor( ColorScheme colorScheme, { - AppSemanticColors? semanticColors, + required AppSemanticColors semanticColors, }) => switch (this) { - GameStatus.waiting => _semantic( - colorScheme, - semanticColors, - ).onWarningContainer, - GameStatus.ready => _semantic(colorScheme, semanticColors).onInfoContainer, - GameStatus.active => _semantic( - colorScheme, - semanticColors, - ).onSuccessContainer, + GameStatus.waiting => semanticColors.onWarningContainer, + GameStatus.ready => semanticColors.onInfoContainer, + GameStatus.active => semanticColors.onSuccessContainer, GameStatus.finished || GameStatus.unknownDefaultOpenApi => colorScheme.onSurfaceVariant, GameStatus.aborted => colorScheme.onErrorContainer, @@ -84,31 +73,27 @@ extension OutcomeResultUI on OutcomeResultEnum? { }; /// Returns the semantic foreground associated with this result. - Color color(ColorScheme colorScheme, {AppSemanticColors? semanticColors}) => - switch (this) { - OutcomeResultEnum.win => _semantic(colorScheme, semanticColors).success, - OutcomeResultEnum.loss => colorScheme.error, - OutcomeResultEnum.draw => _semantic(colorScheme, semanticColors).info, - OutcomeResultEnum.eliminated => colorScheme.error, - OutcomeResultEnum.unknownDefaultOpenApi => colorScheme.onSurfaceVariant, - null => colorScheme.onSurfaceVariant, - }; + Color color( + ColorScheme colorScheme, { + required AppSemanticColors semanticColors, + }) => switch (this) { + OutcomeResultEnum.win => semanticColors.success, + OutcomeResultEnum.loss => colorScheme.error, + OutcomeResultEnum.draw => semanticColors.info, + OutcomeResultEnum.eliminated => colorScheme.error, + OutcomeResultEnum.unknownDefaultOpenApi => colorScheme.onSurfaceVariant, + null => colorScheme.onSurfaceVariant, + }; /// Returns the semantic container associated with this result. Color containerColor( ColorScheme colorScheme, { - AppSemanticColors? semanticColors, + required AppSemanticColors semanticColors, }) => switch (this) { - OutcomeResultEnum.win => _semantic( - colorScheme, - semanticColors, - ).successContainer, + OutcomeResultEnum.win => semanticColors.successContainer, OutcomeResultEnum.loss || OutcomeResultEnum.eliminated => colorScheme.errorContainer, - OutcomeResultEnum.draw => _semantic( - colorScheme, - semanticColors, - ).infoContainer, + OutcomeResultEnum.draw => semanticColors.infoContainer, OutcomeResultEnum.unknownDefaultOpenApi || null => colorScheme.surfaceContainerHighest, }; @@ -116,18 +101,12 @@ extension OutcomeResultUI on OutcomeResultEnum? { /// Returns the paired foreground for [containerColor]. Color onContainerColor( ColorScheme colorScheme, { - AppSemanticColors? semanticColors, + required AppSemanticColors semanticColors, }) => switch (this) { - OutcomeResultEnum.win => _semantic( - colorScheme, - semanticColors, - ).onSuccessContainer, + OutcomeResultEnum.win => semanticColors.onSuccessContainer, OutcomeResultEnum.loss || OutcomeResultEnum.eliminated => colorScheme.onErrorContainer, - OutcomeResultEnum.draw => _semantic( - colorScheme, - semanticColors, - ).onInfoContainer, + OutcomeResultEnum.draw => semanticColors.onInfoContainer, OutcomeResultEnum.unknownDefaultOpenApi || null => colorScheme.onSurfaceVariant, }; @@ -142,8 +121,3 @@ extension OutcomeResultUI on OutcomeResultEnum? { null => 'Aborted', }; } - -AppSemanticColors _semantic( - ColorScheme colorScheme, - AppSemanticColors? semanticColors, -) => semanticColors ?? AppSemanticColors.forBrightness(colorScheme.brightness); diff --git a/lib/features/game/presentation/screens/game_screen.dart b/lib/features/game/presentation/screens/game_screen.dart index 646649e..c627a34 100644 --- a/lib/features/game/presentation/screens/game_screen.dart +++ b/lib/features/game/presentation/screens/game_screen.dart @@ -28,6 +28,7 @@ import 'package:eigen_flutter/features/game/presentation/widgets/budget_clock.da import 'package:eigen_flutter/features/game/presentation/widgets/turn_countdown.dart'; import 'package:eigen_flutter/features/game/providers/game_providers.dart'; import 'package:eigen_flutter/features/game/providers/game_frame_provider.dart'; +import 'package:eigen_flutter/features/game/utils/bot_compatibility.dart'; import 'package:eigen_flutter/features/social/presentation/widgets/player_profile_sheet.dart'; import 'package:eigen_flutter/shared/widgets/player_avatar.dart'; diff --git a/lib/features/game/presentation/screens/game_screen_pre_game.dart b/lib/features/game/presentation/screens/game_screen_pre_game.dart index e1a498e..1366bf6 100644 --- a/lib/features/game/presentation/screens/game_screen_pre_game.dart +++ b/lib/features/game/presentation/screens/game_screen_pre_game.dart @@ -299,7 +299,7 @@ class _AddBotDialogState extends ConsumerState<_AddBotDialog> { : bots .where( (b) => - widget.schemaVersion <= b.schemaVersion && + b.supportsGameSchema(widget.schemaVersion) && (!widget.rated || b.ratedEligible) && rules.botSeatable( BotSeatableArgs( @@ -384,7 +384,7 @@ class _ParticipantList extends StatelessWidget { // resolves to no player; never open the profile sheet on it. onTap: gp.isDeleted ? null - : () => PlayerProfileSheet.show( + : () => showPlayerProfileSheet( context, playerId: gp.info.id, type: gp.type, diff --git a/lib/features/game/presentation/widgets/play_vs_bot_dialog.dart b/lib/features/game/presentation/widgets/play_vs_bot_dialog.dart index 76a79d9..4567629 100644 --- a/lib/features/game/presentation/widgets/play_vs_bot_dialog.dart +++ b/lib/features/game/presentation/widgets/play_vs_bot_dialog.dart @@ -7,6 +7,7 @@ import 'package:eigen_flutter/core/game/game_module.dart'; import 'package:eigen_api/eigen_api.dart'; import 'package:eigen_flutter/features/game/presentation/widgets/timing_selector.dart'; import 'package:eigen_flutter/features/game/providers/game_providers.dart'; +import 'package:eigen_flutter/features/game/utils/bot_compatibility.dart'; import 'package:eigen_flutter/shared/widgets/adaptive_single_choice.dart'; /// Solo-game picker (the "New Solo Game" FAB): choose an opponent for each bot @@ -113,10 +114,9 @@ class _PlayVsBotDialogState extends ConsumerState<PlayVsBotDialog> { GameModule module, { required bool timed, }) => bots.where((b) { - // Solo creation always targets the latest version, so bots are gated - // against the latest rules unit. + // Solo creation always targets the latest rules unit. final rules = module.latestRules; - if (b.schemaVersion > module.latestSchemaVersion) return false; + if (!b.supportsGameSchema(module.latestSchemaVersion)) return false; // Config gate: the game's own botSeatable rule decides which bots support the // chosen config (the Dart twin of the server's GameRules.botSeatable, which // enforces it at seating). Local UX only, with no network round-trip per config. diff --git a/lib/features/game/providers/game_providers.dart b/lib/features/game/providers/game_providers.dart index 7911ffc..a4c6cdf 100644 --- a/lib/features/game/providers/game_providers.dart +++ b/lib/features/game/providers/game_providers.dart @@ -12,6 +12,7 @@ import 'package:eigen_flutter/core/game/players_context.dart'; import 'package:eigen_flutter/core/storage/storage_provider.dart'; import 'package:eigen_flutter/features/auth/providers/auth_providers.dart'; import 'package:eigen_flutter/features/game/data/game_repository.dart'; +import 'package:eigen_flutter/features/game/utils/bot_compatibility.dart'; import 'package:eigen_flutter/shared/providers/player_providers.dart'; import 'package:flutter_riverpod/experimental/persist.dart'; import 'package:riverpod_annotation/experimental/json_persist.dart'; @@ -117,7 +118,7 @@ bool soloPlayAvailable(Ref ref) { (c) => c is! UntimedConfig, ); final hasUsableBot = bots.any( - (b) => b.schemaVersion <= module.latestSchemaVersion, + (bot) => bot.supportsGameSchema(module.latestSchemaVersion), ); return hasTimedMode && hasUsableBot; } diff --git a/lib/features/game/providers/game_providers.g.dart b/lib/features/game/providers/game_providers.g.dart index b3ffc29..fc574f8 100644 --- a/lib/features/game/providers/game_providers.g.dart +++ b/lib/features/game/providers/game_providers.g.dart @@ -373,7 +373,7 @@ final class SoloPlayAvailableProvider } } -String _$soloPlayAvailableHash() => r'7b8d7806324db9dbdeedb2d26f22f47a47139eef'; +String _$soloPlayAvailableHash() => r'76b252c989ed36711a66ec4b8127fe3f2f5ad69c'; /// The caller's games, "your turn" first then most recently updated. /// diff --git a/lib/features/game/utils/bot_compatibility.dart b/lib/features/game/utils/bot_compatibility.dart new file mode 100644 index 0000000..3be4a17 --- /dev/null +++ b/lib/features/game/utils/bot_compatibility.dart @@ -0,0 +1,11 @@ +import 'package:eigen_api/eigen_api.dart'; + +/// Capability checks shared by every bot picker. +extension BotCompatibility on Bot { + /// Whether this bot can play a game using [gameSchemaVersion]. + /// + /// A bot's [Bot.schemaVersion] is the highest game schema it supports, so an + /// equal or older game is compatible and a newer game is not. + bool supportsGameSchema(int gameSchemaVersion) => + gameSchemaVersion <= schemaVersion; +} diff --git a/lib/features/rating/presentation/extensions/rating_ui.dart b/lib/features/rating/presentation/extensions/rating_ui.dart index 5c988de..8ad0f89 100644 --- a/lib/features/rating/presentation/extensions/rating_ui.dart +++ b/lib/features/rating/presentation/extensions/rating_ui.dart @@ -16,11 +16,12 @@ extension RatingDeltaUi on RatingDelta { /// A gain is a stable success role rather than the app's tertiary brand /// color. Loss remains Material's built-in error role and an unchanged value /// stays neutral. - Color color(ColorScheme colorScheme, {AppSemanticColors? semanticColors}) { + Color color( + ColorScheme colorScheme, { + required AppSemanticColors semanticColors, + }) { if (displayChange > 0) { - return (semanticColors ?? - AppSemanticColors.forBrightness(colorScheme.brightness)) - .success; + return semanticColors.success; } if (displayChange < 0) return colorScheme.error; return colorScheme.onSurfaceVariant; diff --git a/lib/features/social/presentation/social_screen.dart b/lib/features/social/presentation/social_screen.dart index c79f5d2..d764ef6 100644 --- a/lib/features/social/presentation/social_screen.dart +++ b/lib/features/social/presentation/social_screen.dart @@ -321,7 +321,7 @@ class _FriendListTile extends StatelessWidget { return ListTile( selected: selected, onTap: onPlayerSelected == null - ? () => PlayerProfileSheet.show( + ? () => showPlayerProfileSheet( context, playerId: userId, type: SeatTypeEnum.human, @@ -506,7 +506,7 @@ class _AddFriendState extends ConsumerState<_AddFriend> key: ValueKey(user.id), selected: widget.selectedPlayerId == user.id, onTap: widget.onPlayerSelected == null - ? () => PlayerProfileSheet.show( + ? () => showPlayerProfileSheet( context, playerId: user.id, type: SeatTypeEnum.human, diff --git a/lib/features/social/presentation/widgets/player_profile_sheet.dart b/lib/features/social/presentation/widgets/player_profile_sheet.dart index 8fb9c91..5ba538d 100644 --- a/lib/features/social/presentation/widgets/player_profile_sheet.dart +++ b/lib/features/social/presentation/widgets/player_profile_sheet.dart @@ -13,45 +13,32 @@ import 'package:eigen_flutter/shared/providers/player_providers.dart'; import 'package:eigen_flutter/shared/widgets/player_avatar.dart'; import 'package:eigen_flutter/shared/widgets/player_tags.dart'; -/// Backwards-compatible modal presentation for [PlayerProfilePanel]. -/// -/// Prefer [PlayerProfilePanel] when embedding the profile in an expanded web -/// layout. Existing callers can continue to use [show] for a compact modal. -class PlayerProfileSheet extends PlayerProfilePanel { - const PlayerProfileSheet({ - super.key, - required super.playerId, - required super.type, - required super.scrollController, - }); - - /// Shows the profile sheet for [playerId] as a modal bottom sheet. - static void show( - BuildContext context, { - required String playerId, - required SeatTypeEnum type, - }) { - showModalBottomSheet<void>( - context: context, - isScrollControlled: true, - useSafeArea: true, - // Retain the framework Material so M3 supplies the surface, shape, ink, - // and its compact-versus-wide maximum width behavior. - showDragHandle: true, - builder: (sheetContext) => DraggableScrollableSheet( - initialChildSize: 0.5, - minChildSize: 0.35, - maxChildSize: 0.9, - expand: false, - builder: (_, scrollController) => PlayerProfilePanel( - playerId: playerId, - type: type, - scrollController: scrollController, - onBeforeReplay: () => Navigator.of(sheetContext).pop(), - ), +/// Shows a player's profile in the compact modal presentation. +void showPlayerProfileSheet( + BuildContext context, { + required String playerId, + required SeatTypeEnum type, +}) { + showModalBottomSheet<void>( + context: context, + isScrollControlled: true, + useSafeArea: true, + // Retain the framework Material so M3 supplies the surface, shape, ink, + // and its compact-versus-wide maximum width behavior. + showDragHandle: true, + builder: (sheetContext) => DraggableScrollableSheet( + initialChildSize: 0.5, + minChildSize: 0.35, + maxChildSize: 0.9, + expand: false, + builder: (_, scrollController) => PlayerProfilePanel( + playerId: playerId, + type: type, + scrollController: scrollController, + onBeforeReplay: () => Navigator.of(sheetContext).pop(), ), - ); - } + ), + ); } /// Reusable content panel for a player's public profile. @@ -60,7 +47,7 @@ class PlayerProfileSheet extends PlayerProfilePanel { /// players, friendship status with actions to add, accept, decline, or remove. /// Bots and anonymous guests show identity and ratings only. Embed this widget /// in a bounded detail pane for expanded layouts, or use -/// [PlayerProfileSheet.show] for the compact modal presentation. +/// [showPlayerProfileSheet] for the compact modal presentation. class PlayerProfilePanel extends ConsumerWidget { const PlayerProfilePanel({ super.key, diff --git a/test/features/game/presentation/play_vs_bot_dialog_test.dart b/test/features/game/presentation/play_vs_bot_dialog_test.dart new file mode 100644 index 0000000..5cae3d9 --- /dev/null +++ b/test/features/game/presentation/play_vs_bot_dialog_test.dart @@ -0,0 +1,119 @@ +import 'package:eigen_api/eigen_api.dart'; +import 'package:eigen_flutter/core/game/game_creation_spec.dart'; +import 'package:eigen_flutter/core/game/game_module.dart'; +import 'package:eigen_flutter/features/game/presentation/widgets/play_vs_bot_dialog.dart'; +import 'package:eigen_flutter/features/game/providers/game_providers.dart'; +import 'package:eigen_flutter/features/game/utils/bot_compatibility.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../helpers/container.dart'; +import '../../../helpers/fakes.dart'; + +class _SchemaTwoModule extends SampleModule { + const _SchemaTwoModule(); + + @override + Map<int, GameRules> get versions => const {2: SampleRules()}; + + @override + GameCreationSpec get creationSpec => const GameCreationSpec( + minPlayers: 2, + maxPlayers: 2, + timingConfigs: {'Rapid': PerActionConfig(maxSeconds: 60)}, + ); +} + +class _StubAvailableBots extends AvailableBots { + _StubAvailableBots(this.bots); + + final List<Bot> bots; + + @override + Future<List<Bot>> build() async => bots; +} + +Bot _bot(String id, int schemaVersion) => Bot( + id: id, + username: id, + displayName: '$id bot', + avatarUrl: null, + schemaVersion: schemaVersion, + ratedEligible: true, + config: const <String, dynamic>{}, +); + +void main() { + test('bot schema version is an inclusive capability ceiling', () { + final bot = _bot('candidate', 2); + + expect(bot.supportsGameSchema(1), isTrue); + expect(bot.supportsGameSchema(2), isTrue); + expect(bot.supportsGameSchema(3), isFalse); + }); + + group('soloPlayAvailableProvider', () { + for (final testCase in [ + (name: 'rejects an older bot', schemaVersion: 1, expected: false), + ( + name: 'accepts an equal-capability bot', + schemaVersion: 2, + expected: true, + ), + ( + name: 'accepts a newer-capability bot', + schemaVersion: 3, + expected: true, + ), + ]) { + test(testCase.name, () async { + final container = makeContainer( + overrides: [ + currentGameModuleProvider.overrideWithValue( + const _SchemaTwoModule(), + ), + availableBotsProvider.overrideWith( + () => _StubAvailableBots([ + _bot('candidate', testCase.schemaVersion), + ]), + ), + ], + ); + + await container.read(availableBotsProvider.future); + + expect(container.read(soloPlayAvailableProvider), testCase.expected); + }); + } + }); + + testWidgets('dialog offers only bots whose capability covers the game', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentGameModuleProvider.overrideWithValue(const _SchemaTwoModule()), + availableBotsProvider.overrideWith( + () => _StubAvailableBots([ + _bot('older', 1), + _bot('equal', 2), + _bot('newer', 3), + ]), + ), + ], + child: const MaterialApp(home: Scaffold(body: PlayVsBotDialog())), + ), + ); + await tester.pumpAndSettle(); + + final dropdown = tester.widget<DropdownMenu<String>>( + find.byType(DropdownMenu<String>), + ); + expect( + dropdown.dropdownMenuEntries.map((entry) => entry.value), + orderedEquals(['equal', 'newer']), + ); + }); +} diff --git a/test/features/social/presentation/player_profile_panel_test.dart b/test/features/social/presentation/player_profile_panel_test.dart index cb141c3..320078b 100644 --- a/test/features/social/presentation/player_profile_panel_test.dart +++ b/test/features/social/presentation/player_profile_panel_test.dart @@ -150,7 +150,7 @@ void main() { builder: (context, state) => Scaffold( body: Center( child: FilledButton( - onPressed: () => PlayerProfileSheet.show( + onPressed: () => showPlayerProfileSheet( context, playerId: _playerId, type: SeatTypeEnum.bot,