Files
ingtrakapp/lib/utils/app_theme.dart
T

388 lines
14 KiB
Dart

/// Material 3 light and dark themes — charcoal surfaces, mint accent.
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Builds the [ThemeData] used by [MaterialApp] for light and dark mode.
abstract final class AppTheme {
/// Mint / emerald accent from the product look.
static const Color accent = Color(0xFF4FA894);
/// Soft rose for expenses / money out.
static const Color expense = Color(0xFFE07A7A);
/// Near-black charcoal scaffold (dark).
static const Color darkCanvas = Color(0xFF0B0D0C);
static const Color darkSurface = Color(0xFF141716);
static const Color darkContainer = Color(0xFF1E2321);
static const Color darkContainerHigh = Color(0xFF2A302D);
static ThemeData light() => _build(Brightness.light);
static ThemeData dark() => _build(Brightness.dark);
static ColorScheme _scheme(Brightness brightness) {
if (brightness == Brightness.dark) {
return const ColorScheme.dark(
primary: accent,
onPrimary: Color(0xFF06201A),
primaryContainer: Color(0xFF1F4F44),
onPrimaryContainer: Color(0xFFB8E6D8),
secondary: Color(0xFF8FB5AB),
onSecondary: Color(0xFF0B1F1A),
secondaryContainer: Color(0xFF2A403A),
onSecondaryContainer: Color(0xFFD0E8E0),
tertiary: expense,
onTertiary: Color(0xFF2A0F0F),
tertiaryContainer: Color(0xFF5C2E2E),
onTertiaryContainer: Color(0xFFFFDAD6),
error: expense,
onError: Color(0xFF2A0F0F),
errorContainer: Color(0xFF5C2E2E),
onErrorContainer: Color(0xFFFFDAD6),
surface: darkSurface,
onSurface: Color(0xFFE8EEEC),
onSurfaceVariant: Color(0xFFA3AFA9),
surfaceContainerLowest: darkCanvas,
surfaceContainerLow: Color(0xFF171B19),
surfaceContainer: darkContainer,
surfaceContainerHigh: darkContainerHigh,
surfaceContainerHighest: Color(0xFF343B38),
outline: Color(0xFF6B7671),
outlineVariant: Color(0xFF3A423E),
shadow: Colors.black,
scrim: Colors.black,
inverseSurface: Color(0xFFE8EEEC),
onInverseSurface: Color(0xFF1A1F1D),
inversePrimary: Color(0xFF0F766E),
);
}
return ColorScheme.fromSeed(
seedColor: accent,
brightness: Brightness.light,
primary: const Color(0xFF1F6B5C),
error: const Color(0xFFC45C5C),
).copyWith(
surface: const Color(0xFFF4F7F5),
surfaceContainerLowest: const Color(0xFFFFFFFF),
surfaceContainerLow: const Color(0xFFEEF3F0),
surfaceContainer: const Color(0xFFE6EDE9),
surfaceContainerHigh: const Color(0xFFDCE6E1),
surfaceContainerHighest: const Color(0xFFD0DCD6),
onSurface: const Color(0xFF15201C),
onSurfaceVariant: const Color(0xFF4A5A54),
outline: const Color(0xFF7A8A83),
outlineVariant: const Color(0xFFC5D0CB),
tertiary: expense,
error: expense,
);
}
static ThemeData _build(Brightness brightness) {
final colorScheme = _scheme(brightness);
final isDark = brightness == Brightness.dark;
final textTheme = _textTheme(colorScheme);
const radius = 14.0;
const radiusLg = 18.0;
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: colorScheme,
textTheme: textTheme,
scaffoldBackgroundColor:
isDark ? darkCanvas : colorScheme.surfaceContainerLowest,
canvasColor: isDark ? darkCanvas : colorScheme.surface,
appBarTheme: AppBarTheme(
centerTitle: false,
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: isDark ? darkCanvas : colorScheme.surfaceContainerLowest,
foregroundColor: colorScheme.onSurface,
titleTextStyle: textTheme.titleLarge,
systemOverlayStyle: isDark
? SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: darkCanvas,
)
: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
),
),
cardTheme: CardThemeData(
elevation: 0,
color: colorScheme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg),
),
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
),
navigationBarTheme: NavigationBarThemeData(
elevation: 0,
height: 72,
backgroundColor: isDark ? darkSurface : colorScheme.surfaceContainer,
indicatorColor: colorScheme.primary.withValues(alpha: 0.22),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return textTheme.labelMedium?.copyWith(
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? colorScheme.primary : colorScheme.onSurfaceVariant,
);
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return IconThemeData(
color: selected ? colorScheme.primary : colorScheme.onSurfaceVariant,
);
}),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceContainerHigh,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radius),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radius),
borderSide: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radius),
borderSide: BorderSide(color: colorScheme.primary, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
hintStyle: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.75),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
),
textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
textStyle: textTheme.labelLarge,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.onSurface,
side: BorderSide(color: colorScheme.outlineVariant),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
),
minimumSize: const Size(48, 44),
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
foregroundColor: colorScheme.onSurfaceVariant,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg),
),
),
dividerTheme: DividerThemeData(
color: colorScheme.outlineVariant.withValues(alpha: 0.55),
space: 1,
thickness: 1,
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: colorScheme.inverseSurface,
contentTextStyle: textTheme.bodyMedium?.copyWith(
color: colorScheme.onInverseSurface,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
),
),
listTileTheme: ListTileThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
),
iconColor: colorScheme.primary,
textColor: colorScheme.onSurface,
),
chipTheme: ChipThemeData(
backgroundColor: colorScheme.surfaceContainerHigh,
selectedColor: colorScheme.primary.withValues(alpha: 0.28),
deleteIconColor: colorScheme.onSurfaceVariant,
labelStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
secondaryLabelStyle: textTheme.labelMedium,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
side: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
dialogTheme: DialogThemeData(
backgroundColor: colorScheme.surfaceContainer,
surfaceTintColor: Colors.transparent,
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
titleTextStyle: textTheme.titleLarge,
contentTextStyle: textTheme.bodyMedium,
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: colorScheme.surfaceContainer,
surfaceTintColor: Colors.transparent,
elevation: 8,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
dragHandleColor: colorScheme.outlineVariant,
showDragHandle: true,
),
// Avoid textStyle here — SegmentedButton lerps selected/unselected styles
// and crashes if inherit flags disagree.
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return colorScheme.primary;
}
return colorScheme.surfaceContainerHigh;
}),
foregroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return colorScheme.onPrimary;
}
return colorScheme.onSurfaceVariant;
}),
side: WidgetStatePropertyAll(
BorderSide(color: colorScheme.outlineVariant.withValues(alpha: 0.7)),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
visualDensity: VisualDensity.compact,
),
),
sliderTheme: SliderThemeData(
activeTrackColor: colorScheme.primary,
inactiveTrackColor: colorScheme.outlineVariant.withValues(alpha: 0.5),
thumbColor: colorScheme.primary,
overlayColor: colorScheme.primary.withValues(alpha: 0.16),
trackHeight: 4,
),
checkboxTheme: CheckboxThemeData(
fillColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return colorScheme.primary;
}
return Colors.transparent;
}),
checkColor: WidgetStatePropertyAll(colorScheme.onPrimary),
side: BorderSide(color: colorScheme.outline, width: 1.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
progressIndicatorTheme: ProgressIndicatorThemeData(
color: colorScheme.primary,
linearTrackColor: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
shadowColor: isDark ? Colors.black : const Color(0x1A0F172A),
);
}
/// Built from Material 2021 typography so every style shares `inherit: false`
/// and theme animations / SegmentedButton can lerp safely.
static TextTheme _textTheme(ColorScheme colors) {
final base = Typography.material2021().englishLike.apply(
bodyColor: colors.onSurface,
displayColor: colors.onSurface,
);
return base.copyWith(
displaySmall: base.displaySmall?.copyWith(
fontSize: 36,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
),
headlineMedium: base.headlineMedium?.copyWith(
fontSize: 28,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
),
titleLarge: base.titleLarge?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
titleMedium: base.titleMedium?.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
),
titleSmall: base.titleSmall?.copyWith(
fontSize: 14,
fontWeight: FontWeight.w600,
),
bodyLarge: base.bodyLarge?.copyWith(
fontSize: 16,
height: 1.4,
fontWeight: FontWeight.w500,
),
bodyMedium: base.bodyMedium?.copyWith(
fontSize: 14,
height: 1.4,
color: colors.onSurfaceVariant,
),
bodySmall: base.bodySmall?.copyWith(
fontSize: 12,
height: 1.35,
color: colors.onSurfaceVariant,
),
labelLarge: base.labelLarge?.copyWith(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
labelMedium: base.labelMedium?.copyWith(
fontSize: 12,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
color: colors.onSurfaceVariant,
),
labelSmall: base.labelSmall?.copyWith(
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.3,
color: colors.onSurfaceVariant,
),
);
}
}