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 0000000..90b406a Binary files /dev/null and b/assets/google/sign_in_with_google_dark_2x.png differ diff --git a/assets/google/sign_in_with_google_light_2x.png b/assets/google/sign_in_with_google_light_2x.png new file mode 100644 index 0000000..0d6f4df Binary files /dev/null and b/assets/google/sign_in_with_google_light_2x.png differ diff --git a/example/web/icons/eigen-mark-192.png b/example/web/icons/eigen-mark-192.png new file mode 100644 index 0000000..242cb6b Binary files /dev/null and b/example/web/icons/eigen-mark-192.png differ diff --git a/example/web/icons/eigen-mark-512.png b/example/web/icons/eigen-mark-512.png new file mode 100644 index 0000000..e3a1453 Binary files /dev/null and b/example/web/icons/eigen-mark-512.png differ 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 0000000..0a2bbdf Binary files /dev/null and b/example/web/icons/eigen-mark-maskable-512.png differ 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/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/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..eaf568a 100644 --- a/lib/features/game/presentation/extensions/game_ui.dart +++ b/lib/features/game/presentation/extensions/game_ui.dart @@ -1,16 +1,49 @@ 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, + /// Returns the semantic foreground associated with this status. + /// + /// Pass the extension from the active theme so high-contrast mode is + /// 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.outline, + GameStatus.unknownDefaultOpenApi => colorScheme.onSurfaceVariant, + }; + + /// Returns the semantic container associated with this status. + Color containerColor( + ColorScheme colorScheme, { + required AppSemanticColors semanticColors, + }) => switch (this) { + GameStatus.waiting => semanticColors.warningContainer, + GameStatus.ready => semanticColors.infoContainer, + GameStatus.active => semanticColors.successContainer, + GameStatus.finished || + GameStatus.unknownDefaultOpenApi => colorScheme.surfaceContainerHighest, + GameStatus.aborted => colorScheme.errorContainer, + }; + + /// Returns the paired foreground for [containerColor]. + Color onContainerColor( + ColorScheme colorScheme, { + required AppSemanticColors semanticColors, + }) => switch (this) { + GameStatus.waiting => semanticColors.onWarningContainer, + GameStatus.ready => semanticColors.onInfoContainer, + GameStatus.active => semanticColors.onSuccessContainer, + GameStatus.finished || + GameStatus.unknownDefaultOpenApi => colorScheme.onSurfaceVariant, + GameStatus.aborted => colorScheme.onErrorContainer, }; /// Returns the icon associated with this status. @@ -39,14 +72,43 @@ 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, + /// Returns the semantic foreground associated with this result. + Color color( + ColorScheme colorScheme, { + required AppSemanticColors semanticColors, + }) => switch (this) { + OutcomeResultEnum.win => semanticColors.success, OutcomeResultEnum.loss => colorScheme.error, - OutcomeResultEnum.draw => colorScheme.tertiary, + OutcomeResultEnum.draw => semanticColors.info, OutcomeResultEnum.eliminated => colorScheme.error, - OutcomeResultEnum.unknownDefaultOpenApi => colorScheme.outline, - null => colorScheme.outline, + OutcomeResultEnum.unknownDefaultOpenApi => colorScheme.onSurfaceVariant, + null => colorScheme.onSurfaceVariant, + }; + + /// Returns the semantic container associated with this result. + Color containerColor( + ColorScheme colorScheme, { + required AppSemanticColors semanticColors, + }) => switch (this) { + OutcomeResultEnum.win => semanticColors.successContainer, + OutcomeResultEnum.loss || + OutcomeResultEnum.eliminated => colorScheme.errorContainer, + OutcomeResultEnum.draw => semanticColors.infoContainer, + OutcomeResultEnum.unknownDefaultOpenApi || + null => colorScheme.surfaceContainerHighest, + }; + + /// Returns the paired foreground for [containerColor]. + Color onContainerColor( + ColorScheme colorScheme, { + required AppSemanticColors semanticColors, + }) => switch (this) { + OutcomeResultEnum.win => semanticColors.onSuccessContainer, + OutcomeResultEnum.loss || + OutcomeResultEnum.eliminated => colorScheme.onErrorContainer, + OutcomeResultEnum.draw => semanticColors.onInfoContainer, + OutcomeResultEnum.unknownDefaultOpenApi || + null => colorScheme.onSurfaceVariant, }; /// Returns the short display label for this result. 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 96297d2..1366bf6 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)), @@ -298,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( @@ -383,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/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..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,8 @@ 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 /// seat and start a solo game (you + bots) immediately, with no waiting room. @@ -112,10 +114,9 @@ class _PlayVsBotDialogState extends ConsumerState { 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. @@ -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/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/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..8ad0f89 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,22 @@ 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, { + required AppSemanticColors semanticColors, + }) { + if (displayChange > 0) { + return semanticColors.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..d764ef6 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 + ? () => showPlayerProfileSheet( + 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 + ? () => showPlayerProfileSheet( + 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..5ba538d 100644 --- a/lib/features/social/presentation/widgets/player_profile_sheet.dart +++ b/lib/features/social/presentation/widgets/player_profile_sheet.dart @@ -8,25 +8,53 @@ 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. +/// Shows a player's profile in the compact modal presentation. +void showPlayerProfileSheet( + BuildContext context, { + required String playerId, + required SeatTypeEnum type, +}) { + showModalBottomSheet( + 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. /// /// 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 { - const PlayerProfileSheet({ +/// 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 +/// [showPlayerProfileSheet] for the compact modal presentation. +class PlayerProfilePanel extends ConsumerWidget { + const PlayerProfilePanel({ super.key, required this.playerId, required this.type, - required this.scrollController, + this.scrollController, + this.onBeforeReplay, }); /// The player's UUID (human or bot). @@ -35,100 +63,59 @@ class PlayerProfileSheet extends ConsumerWidget { /// 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, { - required String playerId, - required SeatTypeEnum type, - }) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - backgroundColor: Colors.transparent, - builder: (_) => DraggableScrollableSheet( - initialChildSize: 0.5, - minChildSize: 0.35, - maxChildSize: 0.9, - expand: false, - builder: (_, scrollController) => PlayerProfileSheet( - playerId: playerId, - type: type, - scrollController: scrollController, - ), - ), - ); - } + /// 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), - ), - ), - SliverToBoxAdapter( - child: _RecentGamesSection(playerId: playerId, type: type), + 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()), ), - // 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 +246,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 +273,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 +301,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 +323,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 +340,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/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/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..320078b --- /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: () => showPlayerProfileSheet( + 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(); + }); +}