Prepare product layout and reusable Flutter theme

Relocate the existing Astro website and preserve its assets and hosting configuration. Add the consumer/backend scaffold and themed Flutter package with consumer tests. Related to #1.
This commit is contained in:
Stephan D committed 2026-09-11 15:36:58 +02:00
1 parent ef65a40213
commit d9318053e8
46 files changed
+703 -1

No files matched your search

@@ -0,0 +1,19 @@
// Brand snapshot from website/design/tokens.json (version 1).
// Keep these values aligned when deliberately updating the package's brand.
import 'dart:ui';
abstract final class SxchngeBrand {
static const ink = Color(0xFF14221D);
static const mint = Color(0xFFC4F9CE);
static const lime = Color(0xFFD7FF5F);
static const paper = Color(0xFFF9FAF6);
static const muted = Color(0xFF637168);
static const line = Color(0xFFD9E0D8);
static const forest = Color(0xFF396A4B);
static const focus = Color(0xFF168850);
static const darkField = Color(0xFF25372E);
static const darkReceive = Color(0xFF203D2D);
static const sageSurface = Color(0xFFEAF0E6);
static const blueSurface = Color(0xFFEDF0F9);
static const fontFamily = 'Onest';
}
@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'brand.dart';
/// Install with `MaterialApp(theme: SxchngeTheme.light(),
/// darkTheme: SxchngeTheme.dark())`.
abstract final class SxchngeTheme {
static const packageName = 'sxchnge_theme';
static ThemeData light() => _build(Brightness.light);
static ThemeData dark() => _build(Brightness.dark);
static ThemeData _build(Brightness brightness) {
final dark = brightness == Brightness.dark;
// Seed supplies complete Material roles; explicit overrides preserve brand
// colors and readable foregrounds rather than approximating the palette.
final scheme =
ColorScheme.fromSeed(
seedColor: SxchngeBrand.forest,
brightness: brightness,
).copyWith(
primary: dark ? SxchngeBrand.mint : SxchngeBrand.ink,
onPrimary: dark ? SxchngeBrand.ink : SxchngeBrand.mint,
primaryContainer: SxchngeBrand.mint,
onPrimaryContainer: SxchngeBrand.ink,
secondary: dark ? SxchngeBrand.mint : SxchngeBrand.forest,
onSecondary: dark ? SxchngeBrand.ink : SxchngeBrand.paper,
secondaryContainer: dark
? SxchngeBrand.darkReceive
: SxchngeBrand.sageSurface,
onSecondaryContainer: dark ? SxchngeBrand.mint : SxchngeBrand.ink,
tertiary: dark ? SxchngeBrand.lime : SxchngeBrand.forest,
onTertiary: dark ? SxchngeBrand.ink : SxchngeBrand.paper,
tertiaryContainer: SxchngeBrand.lime,
onTertiaryContainer: SxchngeBrand.ink,
surface: dark ? SxchngeBrand.ink : SxchngeBrand.paper,
onSurface: dark ? SxchngeBrand.paper : SxchngeBrand.ink,
onSurfaceVariant: dark ? const Color(0xFFB6C6B9) : SxchngeBrand.muted,
surfaceContainerLowest: dark ? const Color(0xFF101B17) : Colors.white,
surfaceContainerLow: dark
? const Color(0xFF1B2B23)
: SxchngeBrand.paper,
surfaceContainer: dark
? SxchngeBrand.darkField
: SxchngeBrand.sageSurface,
surfaceContainerHigh: dark
? SxchngeBrand.darkReceive
: SxchngeBrand.line,
surfaceContainerHighest: dark
? const Color(0xFF324A3C)
: const Color(0xFFCFD9CE),
outline: dark ? const Color(0xFF8CA291) : SxchngeBrand.muted,
outlineVariant: dark ? const Color(0xFF405A48) : SxchngeBrand.line,
inverseSurface: dark ? SxchngeBrand.paper : SxchngeBrand.ink,
onInverseSurface: dark ? SxchngeBrand.ink : SxchngeBrand.paper,
inversePrimary: dark ? SxchngeBrand.forest : SxchngeBrand.mint,
surfaceTint: Colors.transparent,
);
final base = ThemeData(
useMaterial3: true,
colorScheme: scheme,
fontFamily: SxchngeBrand.fontFamily,
package: packageName,
scaffoldBackgroundColor: scheme.surface,
visualDensity: VisualDensity.standard,
materialTapTargetSize: MaterialTapTargetSize.padded,
);
final text = base.textTheme.copyWith(
displayLarge: base.textTheme.displayLarge!.copyWith(
fontSize: 48,
fontWeight: FontWeight.w600,
letterSpacing: -2,
height: 1.1,
),
displayMedium: base.textTheme.displayMedium!.copyWith(
fontSize: 40,
fontWeight: FontWeight.w600,
letterSpacing: -1.5,
height: 1.15,
),
displaySmall: base.textTheme.displaySmall!.copyWith(
fontSize: 36,
fontWeight: FontWeight.w600,
letterSpacing: -1,
height: 1.2,
),
headlineLarge: base.textTheme.headlineLarge!.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -1,
),
headlineMedium: base.textTheme.headlineMedium!.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
headlineSmall: base.textTheme.headlineSmall!.copyWith(
fontWeight: FontWeight.w600,
),
bodyLarge: base.textTheme.bodyLarge!.copyWith(fontSize: 16, height: 1.65),
bodyMedium: base.textTheme.bodyMedium!.copyWith(
fontSize: 16,
height: 1.5,
),
bodySmall: base.textTheme.bodySmall!.copyWith(fontSize: 14, height: 1.5),
labelLarge: base.textTheme.labelLarge!.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
),
labelMedium: base.textTheme.labelMedium!.copyWith(
fontSize: 14,
fontWeight: FontWeight.w600,
),
labelSmall: base.textTheme.labelSmall!.copyWith(fontSize: 14),
);
final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.outline),
);
return base.copyWith(
textTheme: text,
appBarTheme: AppBarThemeData(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: false,
titleTextStyle: text.titleLarge,
),
cardTheme: CardThemeData(
color: scheme.surfaceContainer,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(64, 48),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: const StadiumBorder(),
textStyle: text.labelLarge,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size(64, 48),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: const StadiumBorder(),
textStyle: text.labelLarge,
elevation: 0,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(64, 48),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: const StadiumBorder(),
textStyle: text.labelLarge,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
minimumSize: const Size(48, 48),
textStyle: text.labelLarge,
),
),
inputDecorationTheme: InputDecorationThemeData(
filled: true,
fillColor: scheme.surfaceContainerLowest,
contentPadding: const EdgeInsets.all(16),
border: border,
enabledBorder: border,
focusedBorder: border.copyWith(
borderSide: BorderSide(
color: dark ? SxchngeBrand.mint : SxchngeBrand.focus,
width: 2,
),
),
errorBorder: border.copyWith(
borderSide: BorderSide(color: scheme.error),
),
focusedErrorBorder: border.copyWith(
borderSide: BorderSide(color: scheme.error, width: 2),
),
),
dialogTheme: DialogThemeData(
backgroundColor: scheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
dividerTheme: DividerThemeData(
color: scheme.outlineVariant,
thickness: 1,
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: scheme.surface,
indicatorColor: scheme.primaryContainer,
),
textSelectionTheme: TextSelectionThemeData(
cursorColor: scheme.primary,
selectionColor: SxchngeBrand.lime.withValues(alpha: dark ? 0.3 : 0.5),
selectionHandleColor: scheme.primary,
),
);
}
}
@@ -0,0 +1,5 @@
/// SXCHNGE brand colors, typography, and Material themes.
library;
export 'src/brand.dart';
export 'src/theme.dart';