Skip to content
This repository was archived by the owner on Sep 10, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions assets/google/README.md
Original file line number Diff line number Diff line change
@@ -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:

<https://developers.google.com/static/identity/images/signin-assets.zip>

Do not recolor, redraw, crop, or change the aspect ratio. When Google updates
its branding requirements, replace these files from the official bundle.
Binary file added assets/google/sign_in_with_google_dark_2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/google/sign_in_with_google_light_2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added example/web/icons/eigen-mark-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added example/web/icons/eigen-mark-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added example/web/icons/eigen-mark-maskable-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions example/web/icons/eigen-mark-maskable.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions example/web/icons/eigen-mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 5 additions & 1 deletion example/web/index.html
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="Rock Paper Scissors on EigenInteractive">
<meta name="theme-color" content="#00695c">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="RPS">
<link rel="icon" type="image/svg+xml" href="icons/eigen-mark.svg">
<link rel="apple-touch-icon" href="icons/eigen-mark-192.png">
<title>Rock Paper Scissors</title>
<link rel="manifest" href="manifest.json">
</head>
Expand Down
29 changes: 28 additions & 1 deletion example/web/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
25 changes: 25 additions & 0 deletions lib/app_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,6 +43,13 @@ Future<void> 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);

Expand Down Expand Up @@ -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(),
),
);
}
}
114 changes: 114 additions & 0 deletions lib/core/adaptive/adaptive_layout.dart
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions lib/core/licenses/bundled_font_licenses.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}
8 changes: 3 additions & 5 deletions lib/core/navigation/providers/navigation_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion lib/core/navigation/providers/navigation_providers.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 62 additions & 0 deletions lib/core/navigation/widgets/app_route_title.dart
Original file line number Diff line number Diff line change
@@ -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',
};
}
Loading