conduit/lib/shared/theme/app_theme.dart

461 lines
15 KiB
Dart

import 'dart:math' as math;
import 'package:material_ui/material_ui.dart';
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../core/utils/system_ui_style.dart';
import 'theme_extensions.dart';
import 'tweakcn_themes.dart';
import 'color_tokens.dart';
class AppTheme {
static ThemeData light(TweakcnThemeDefinition theme) {
final tokens = AppColorTokens.light(theme: theme);
return _buildTheme(
theme: theme,
tokens: tokens,
brightness: Brightness.light,
);
}
static ThemeData dark(TweakcnThemeDefinition theme) {
final tokens = AppColorTokens.dark(theme: theme);
return _buildTheme(
theme: theme,
tokens: tokens,
brightness: Brightness.dark,
);
}
/// Builds a [CupertinoThemeData] for light mode.
static CupertinoThemeData cupertinoLight(TweakcnThemeDefinition theme) {
final variant = theme.variantFor(Brightness.light);
final tokens = AppColorTokens.light(theme: theme);
return _buildCupertinoThemeData(
brightness: Brightness.light,
variant: variant,
tokens: tokens,
);
}
/// Builds a [CupertinoThemeData] for dark mode.
static CupertinoThemeData cupertinoDark(TweakcnThemeDefinition theme) {
final variant = theme.variantFor(Brightness.dark);
final tokens = AppColorTokens.dark(theme: theme);
return _buildCupertinoThemeData(
brightness: Brightness.dark,
variant: variant,
tokens: tokens,
);
}
static ThemeData _buildTheme({
required TweakcnThemeDefinition theme,
required AppColorTokens tokens,
required Brightness brightness,
}) {
final variant = theme.variantFor(brightness);
final isDark = brightness == Brightness.dark;
final surfaces = SurfaceThemeExtension.fromVariant(variant);
final shadows = ShadowThemeExtension.standard();
final shapes = ShapeThemeExtension.fromVariant(variant);
final sidebar = SidebarThemeExtension.fromVariant(variant);
final conduitExtension = ConduitThemeExtension.create(
theme: theme,
tokens: tokens,
brightness: brightness,
surfaces: surfaces,
shadows: shadows,
shapes: shapes,
);
final colorScheme = tokens.toColorScheme().copyWith(
primary: variant.primary,
onPrimary: _pickOnColor(variant.primary, tokens),
secondary: variant.secondary,
onSecondary: _pickOnColor(variant.secondary, tokens),
tertiary: variant.accent,
onTertiary: _pickOnColor(variant.accent, tokens),
surfaceTint: variant.primary,
);
final OutlineInputBorder baseInputBorder = OutlineInputBorder(
borderRadius: shapes.medium,
borderSide: BorderSide(
color: isDark
? Color.lerp(surfaces.border, surfaces.input, 0.6)!
: Color.lerp(surfaces.border, surfaces.input, 0.4)!,
width: 1,
),
);
final TextTheme textTheme = _buildTextTheme(tokens: tokens, isDark: isDark);
final TextTheme materialChromeTextTheme =
AppTypography.materialChromeTextTheme(
primary: tokens.neutralOnSurface,
secondary: isDark ? tokens.neutralTone80 : tokens.neutralTone60,
tertiary: tokens.neutralTone60,
);
final cupertinoOverrideTheme = _buildCupertinoThemeData(
brightness: brightness,
variant: variant,
tokens: tokens,
);
final textInputAccentColor = variant.primary;
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: colorScheme,
scaffoldBackgroundColor: surfaces.background,
canvasColor: surfaces.background,
cupertinoOverrideTheme: cupertinoOverrideTheme,
pageTransitionsTheme: _pageTransitionsTheme,
splashFactory: NoSplash.splashFactory,
appBarTheme: AppBarTheme(
centerTitle: true,
elevation: Elevation.none,
backgroundColor: surfaces.background,
foregroundColor: tokens.neutralOnSurface,
titleTextStyle: materialChromeTextTheme.titleLarge,
toolbarTextStyle: materialChromeTextTheme.bodyMedium,
systemOverlayStyle: systemUiOverlayStyleForBrightness(brightness),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: surfaces.card,
modalBackgroundColor: surfaces.card,
surfaceTintColor: surfaces.card,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.bottomSheet),
),
),
clipBehavior: Clip.antiAlias,
showDragHandle: false,
),
cardTheme: CardThemeData(
color: surfaces.card,
elevation: Elevation.low,
shape: RoundedRectangleBorder(
borderRadius: shapes.large,
side: BorderSide(color: surfaces.border),
),
shadowColor: shadows.shadowSm.first.color,
surfaceTintColor: Colors.transparent,
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: conduitExtension.statusPalette.info.base,
contentTextStyle: textTheme.bodyMedium?.copyWith(
color: conduitExtension.statusPalette.info.onBase,
),
actionTextColor: conduitExtension.statusPalette.info.onBase,
shape: RoundedRectangleBorder(borderRadius: shapes.medium),
elevation: Elevation.low,
insetPadding: const EdgeInsets.all(Spacing.md),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: conduitExtension.inputBackground,
focusColor: surfaces.ring,
hoverColor: Color.alphaBlend(
shadows.shadowXs.first.color,
conduitExtension.inputBackground,
),
hintStyle: AppTypography.inputHintStyle.copyWith(
color: conduitExtension.inputPlaceholder,
),
border: baseInputBorder,
enabledBorder: baseInputBorder,
focusedBorder: baseInputBorder.copyWith(
borderSide: BorderSide(color: surfaces.ring, width: 2),
),
errorBorder: baseInputBorder.copyWith(
borderSide: BorderSide(color: tokens.statusError60, width: 1),
),
contentPadding: EdgeInsets.symmetric(
horizontal: Spacing.inputPadding,
vertical: AppTypography.inputVerticalPadding,
),
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: shapes.medium),
padding: EdgeInsets.symmetric(
horizontal: AppTypography.chipHorizontalPadding,
vertical: AppTypography.chipVerticalPadding,
),
backgroundColor: Color.lerp(surfaces.card, surfaces.muted, 0.4)!,
disabledColor: Color.alphaBlend(
shadows.shadowXs.first.color,
surfaces.card,
),
selectedColor: conduitExtension.statusPalette.success.background,
secondarySelectedColor: conduitExtension.statusPalette.info.background,
shadowColor: shadows.shadowSm.first.color,
selectedShadowColor: shadows.shadowSm.first.color,
brightness: brightness,
labelStyle: textTheme.bodySmall?.copyWith(
color: tokens.neutralOnSurface,
),
secondaryLabelStyle: textTheme.bodySmall?.copyWith(
color: conduitExtension.statusPalette.info.onBase,
),
side: BorderSide(color: surfaces.border),
),
badgeTheme: BadgeThemeData(
backgroundColor: conduitExtension.statusPalette.info.base,
textColor: conduitExtension.statusPalette.info.onBase,
padding: EdgeInsets.symmetric(
horizontal: AppTypography.badgeHorizontalPadding,
vertical: AppTypography.badgeVerticalPadding,
),
largeSize: AppTypography.badgeLargeSize,
smallSize: AppTypography.badgeSmallSize,
),
dialogTheme: DialogThemeData(
backgroundColor: surfaces.popover,
surfaceTintColor: Colors.transparent,
elevation: Elevation.medium,
shadowColor: shadows.shadowLg.first.color,
shape: RoundedRectangleBorder(borderRadius: shapes.large),
titleTextStyle: textTheme.titleLarge?.copyWith(
color: surfaces.popoverForeground,
),
contentTextStyle: textTheme.bodyMedium?.copyWith(
color: tokens.neutralOnSurface,
),
),
listTileTheme: ListTileThemeData(
shape: RoundedRectangleBorder(borderRadius: shapes.medium),
tileColor: Color.lerp(surfaces.card, surfaces.muted, 0.25),
selectedTileColor: Color.alphaBlend(
conduitExtension.statusPalette.info.background,
surfaces.card,
),
iconColor: tokens.neutralTone80,
textColor: tokens.neutralOnSurface,
),
popupMenuTheme: PopupMenuThemeData(
color: surfaces.popover,
surfaceTintColor: Colors.transparent,
elevation: Elevation.high,
shadowColor: shadows.shadowLg.first.color,
shape: RoundedRectangleBorder(
borderRadius: shapes.large,
side: BorderSide(
color: surfaces.border.withValues(alpha: 0.15),
width: 0.5,
),
),
textStyle: textTheme.bodyMedium?.copyWith(
color: tokens.neutralOnSurface,
),
labelTextStyle: WidgetStateProperty.all(
textTheme.bodyMedium?.copyWith(
color: tokens.neutralOnSurface,
fontWeight: FontWeight.w500,
),
),
),
textTheme: textTheme,
textSelectionTheme: TextSelectionThemeData(
// Keep cursor, handles, and selection highlight on the active theme
// accent across both Material and Cupertino text fields.
cursorColor: textInputAccentColor,
selectionColor: textInputAccentColor.withValues(alpha: 0.2),
selectionHandleColor: textInputAccentColor,
),
extensions: <ThemeExtension<dynamic>>[
tokens,
surfaces,
shadows,
shapes,
sidebar,
conduitExtension,
AppPaletteThemeExtension(palette: theme),
],
);
}
static Color _pickOnColor(Color background, AppColorTokens tokens) {
final contrastOnLight = _contrastRatio(background, tokens.neutralTone00);
final contrastOnDark = _contrastRatio(background, tokens.neutralOnSurface);
return contrastOnLight >= contrastOnDark
? tokens.neutralTone00
: tokens.neutralOnSurface;
}
static TextTheme _buildTextTheme({
required AppColorTokens tokens,
required bool isDark,
}) {
final primary = tokens.neutralOnSurface;
final secondary = isDark ? tokens.neutralTone80 : tokens.neutralTone60;
final tertiary = tokens.neutralTone60;
return AppTypography.textTheme(
primary: primary,
secondary: secondary,
tertiary: tertiary,
);
}
static CupertinoThemeData _buildCupertinoThemeData({
required Brightness brightness,
required TweakcnThemeVariant variant,
required AppColorTokens tokens,
}) {
final isDark = brightness == Brightness.dark;
final primaryText = tokens.neutralOnSurface;
final secondaryText = isDark ? tokens.neutralTone80 : tokens.neutralTone60;
final actionColor = variant.primary;
final chromeTextTheme = AppTypography.cupertinoChromeTextTheme(
primary: primaryText,
secondary: secondaryText,
tertiary: secondaryText,
);
return CupertinoThemeData(
brightness: brightness,
primaryColor: actionColor,
scaffoldBackgroundColor: tokens.neutralTone10,
barBackgroundColor: tokens.neutralTone10,
textTheme: CupertinoTextThemeData(
textStyle: chromeTextTheme.bodyLarge,
actionTextStyle: chromeTextTheme.bodyLarge?.copyWith(
color: actionColor,
),
actionSmallTextStyle: chromeTextTheme.bodyMedium?.copyWith(
color: actionColor,
),
tabLabelTextStyle: AppTypography.cupertinoChromeMicroStyle.copyWith(
color: secondaryText,
),
navTitleTextStyle: chromeTextTheme.titleLarge,
navLargeTitleTextStyle: chromeTextTheme.displayLarge,
navActionTextStyle: chromeTextTheme.bodyLarge?.copyWith(
color: actionColor,
),
pickerTextStyle: chromeTextTheme.titleLarge,
dateTimePickerTextStyle: chromeTextTheme.titleLarge,
),
);
}
static double _contrastRatio(Color a, Color b) {
final luminanceA = a.computeLuminance();
final luminanceB = b.computeLuminance();
final lighter = math.max(luminanceA, luminanceB);
final darker = math.min(luminanceA, luminanceB);
return (lighter + 0.05) / (darker + 0.05);
}
static const PageTransitionsTheme _pageTransitionsTheme =
PageTransitionsTheme(
builders: <TargetPlatform, PageTransitionsBuilder>{
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.linux: ZoomPageTransitionsBuilder(),
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.windows: ZoomPageTransitionsBuilder(),
},
);
}
/// Animated theme wrapper for smooth theme transitions
class AnimatedThemeWrapper extends StatefulWidget {
final Widget child;
final ThemeData theme;
final Duration duration;
const AnimatedThemeWrapper({
super.key,
required this.child,
required this.theme,
this.duration = const Duration(milliseconds: 250),
});
@override
State<AnimatedThemeWrapper> createState() => _AnimatedThemeWrapperState();
}
class _AnimatedThemeWrapperState extends State<AnimatedThemeWrapper>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
ThemeData? _previousTheme;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: widget.duration, vsync: this);
_animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOut);
_previousTheme = widget.theme;
}
@override
void didUpdateWidget(AnimatedThemeWrapper oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.theme != widget.theme) {
_previousTheme = oldWidget.theme;
_controller.forward(from: 0);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void deactivate() {
// Pause animations during deactivation to avoid rebuilds in wrong build scope
_controller.stop();
super.deactivate();
}
@override
void activate() {
super.activate();
// If a theme transition was in progress, resume it
if (_controller.value < 1.0 && !_controller.isAnimating) {
_controller.forward();
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Theme(
data: ThemeData.lerp(
_previousTheme ?? widget.theme,
widget.theme,
_animation.value,
),
child: widget.child,
);
},
);
}
}
/// Theme transition widget for individual components
class ThemeTransition extends StatelessWidget {
final Widget child;
final Duration duration;
const ThemeTransition({
super.key,
required this.child,
this.duration = const Duration(milliseconds: 200),
});
@override
Widget build(BuildContext context) {
return child.animate().fadeIn(duration: duration);
}
}
// Typography, spacing, and design token classes are now in theme_extensions.dart for consistency