diff --git a/lib/app/app.dart b/lib/app/app.dart index cf3acbca..04e14f58 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -25,10 +25,12 @@ class _XWorkmateAppState extends State { ); late final AppController _controller; + late final AppThemeSurface _themeSurface; @override void initState() { super.initState(); + _themeSurface = resolveAppThemeSurface(); _controller = AppController( uiFeatureManifest: widget.featureManifest ?? UiFeatureManifest.fallback(), ); @@ -88,8 +90,8 @@ class _XWorkmateAppState extends State { supportedLocales: const [Locale('zh'), Locale('en')], localizationsDelegates: GlobalMaterialLocalizations.delegates, themeMode: _controller.themeMode, - theme: AppTheme.light(), - darkTheme: AppTheme.dark(), + theme: AppTheme.light(surface: _themeSurface), + darkTheme: AppTheme.dark(surface: _themeSurface), home: AppShell(controller: _controller), ); }, diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 28973eb3..abdfbb3a 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -3,6 +3,23 @@ import 'package:flutter/material.dart'; import 'app_palette.dart'; +enum AppThemeSurface { desktop, web, mobile } + +AppThemeSurface resolveAppThemeSurface({ + TargetPlatform? platform, + bool isWeb = kIsWeb, +}) { + if (isWeb) { + return AppThemeSurface.web; + } + final resolvedPlatform = platform ?? defaultTargetPlatform; + if (resolvedPlatform == TargetPlatform.iOS || + resolvedPlatform == TargetPlatform.android) { + return AppThemeSurface.mobile; + } + return AppThemeSurface.desktop; +} + // Default theme token set: simple class SimpleSpacing { SimpleSpacing._(); @@ -166,31 +183,36 @@ class AppSizes { } class AppTheme { - static ThemeData light({TargetPlatform? platform}) => _theme( + static ThemeData light({ + TargetPlatform? platform, + AppThemeSurface? surface, + }) => _theme( brightness: Brightness.light, palette: AppPalette.light, platform: platform, + surface: surface, ); - static ThemeData dark({TargetPlatform? platform}) => _theme( + static ThemeData dark({ + TargetPlatform? platform, + AppThemeSurface? surface, + }) => _theme( brightness: Brightness.dark, palette: AppPalette.dark, platform: platform, + surface: surface, ); static ThemeData _theme({ required Brightness brightness, required AppPalette palette, TargetPlatform? platform, + AppThemeSurface? surface, }) { final resolvedPlatform = platform ?? defaultTargetPlatform; - final isDesktop = - resolvedPlatform == TargetPlatform.macOS || - resolvedPlatform == TargetPlatform.windows || - resolvedPlatform == TargetPlatform.linux; - final isMobile = - resolvedPlatform == TargetPlatform.iOS || - resolvedPlatform == TargetPlatform.android; + final resolvedSurface = + surface ?? resolveAppThemeSurface(platform: resolvedPlatform); + final scene = _AppThemeSceneConfig.fromSurface(resolvedSurface); final colorScheme = ColorScheme.fromSeed( seedColor: palette.accent, @@ -229,16 +251,14 @@ class AppTheme { final tunedTextTheme = _textTheme( base.textTheme, palette: palette, - isMobile: isMobile, + useCompactDisplay: scene.useCompactDisplay, ); return base.copyWith( platform: resolvedPlatform, splashFactory: NoSplash.splashFactory, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: isDesktop - ? const VisualDensity(horizontal: -1, vertical: -1) - : VisualDensity.standard, + visualDensity: scene.visualDensity, dividerColor: palette.strokeSoft, hoverColor: palette.hover, textTheme: tunedTextTheme, @@ -285,9 +305,7 @@ class AppTheme { minimumSize: WidgetStatePropertyAll( Size( 0, - isDesktop - ? AppSizes.buttonHeightDesktop - : AppSizes.buttonHeightMobile, + scene.buttonHeight, ), ), padding: const WidgetStatePropertyAll( @@ -318,9 +336,7 @@ class AppTheme { ), minimumSize: Size( 0, - isDesktop - ? AppSizes.buttonHeightDesktop - : AppSizes.buttonHeightMobile, + scene.buttonHeight, ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0), shape: RoundedRectangleBorder( @@ -337,9 +353,7 @@ class AppTheme { ), minimumSize: Size( 0, - isDesktop - ? AppSizes.buttonHeightDesktop - : AppSizes.buttonHeightMobile, + scene.buttonHeight, ), padding: const EdgeInsets.symmetric( horizontal: AppSpacing.sm, @@ -446,7 +460,7 @@ class AppTheme { static TextTheme _textTheme( TextTheme base, { required AppPalette palette, - required bool isMobile, + required bool useCompactDisplay, }) { TextStyle withUiFont(TextStyle? style) { return (style ?? const TextStyle()).copyWith( @@ -459,10 +473,10 @@ class AppTheme { return base.copyWith( displaySmall: withUiFont( base.displaySmall?.copyWith( - fontSize: isMobile ? 24 : AppTypography.displaySize, + fontSize: useCompactDisplay ? 24 : AppTypography.displaySize, fontWeight: AppTypography.displayWeight, - letterSpacing: isMobile ? -0.24 : -0.32, - height: isMobile ? 28 / 24 : AppTypography.displayHeight, + letterSpacing: useCompactDisplay ? -0.24 : -0.32, + height: useCompactDisplay ? 28 / 24 : AppTypography.displayHeight, color: palette.textPrimary, ), ), @@ -553,3 +567,35 @@ class AppTheme { ); } } + +class _AppThemeSceneConfig { + const _AppThemeSceneConfig({ + required this.visualDensity, + required this.buttonHeight, + required this.useCompactDisplay, + }); + + final VisualDensity visualDensity; + final double buttonHeight; + final bool useCompactDisplay; + + factory _AppThemeSceneConfig.fromSurface(AppThemeSurface surface) { + return switch (surface) { + AppThemeSurface.desktop => const _AppThemeSceneConfig( + visualDensity: VisualDensity(horizontal: -1, vertical: -1), + buttonHeight: AppSizes.buttonHeightDesktop, + useCompactDisplay: false, + ), + AppThemeSurface.web => const _AppThemeSceneConfig( + visualDensity: VisualDensity(horizontal: -1, vertical: -1), + buttonHeight: AppSizes.buttonHeightDesktop, + useCompactDisplay: false, + ), + AppThemeSurface.mobile => const _AppThemeSceneConfig( + visualDensity: VisualDensity.standard, + buttonHeight: AppSizes.buttonHeightMobile, + useCompactDisplay: true, + ), + }; + } +} diff --git a/test/theme/app_theme_suite.dart b/test/theme/app_theme_suite.dart index 83dbc221..1c76d07e 100644 --- a/test/theme/app_theme_suite.dart +++ b/test/theme/app_theme_suite.dart @@ -6,6 +6,29 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/theme/app_theme.dart'; void main() { + test('AppTheme resolves desktop, web, and mobile surfaces explicitly', () { + expect( + resolveAppThemeSurface(platform: TargetPlatform.macOS, isWeb: false), + AppThemeSurface.desktop, + ); + expect( + resolveAppThemeSurface(platform: TargetPlatform.windows, isWeb: false), + AppThemeSurface.desktop, + ); + expect( + resolveAppThemeSurface(platform: TargetPlatform.android, isWeb: false), + AppThemeSurface.mobile, + ); + expect( + resolveAppThemeSurface(platform: TargetPlatform.iOS, isWeb: false), + AppThemeSurface.mobile, + ); + expect( + resolveAppThemeSurface(platform: TargetPlatform.macOS, isWeb: true), + AppThemeSurface.web, + ); + }); + test('AppTheme uses compact mobile typography on iOS and Android', () { final iosTheme = AppTheme.light(platform: TargetPlatform.iOS); final androidTheme = AppTheme.light(platform: TargetPlatform.android); @@ -29,12 +52,21 @@ void main() { test('AppTheme keeps larger display typography on desktop surfaces', () { final desktopTheme = AppTheme.light(platform: TargetPlatform.macOS); + final webTheme = AppTheme.light( + platform: TargetPlatform.macOS, + surface: AppThemeSurface.web, + ); expect(desktopTheme.textTheme.displaySmall?.fontSize, 28); + expect(webTheme.textTheme.displaySmall?.fontSize, 28); expect( desktopTheme.filledButtonTheme.style?.minimumSize?.resolve({})?.height, AppSizes.buttonHeightDesktop, ); + expect( + webTheme.filledButtonTheme.style?.minimumSize?.resolve({})?.height, + AppSizes.buttonHeightDesktop, + ); }); test('AppTheme matches calm compact workspace baseline tokens', () {