570 lines
19 KiB
Dart
570 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Named palette. Every colour used anywhere in the app is named here — never
|
|
// write a hex literal at a call site. Brightness-aware surfaces still come from
|
|
// [JpColors] via `context.jp`; these constants are the fixed brand tones and
|
|
// the defaults used where no context is available.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
var colorWhite = const Color(0xFFFFFFFF);
|
|
var colorPrimary = const Color(0xFFE8451F); // vermilion — brand primary
|
|
var colorPrimaryDark = const Color(0xFF111823); // near-black ink
|
|
var colorPrimaryDark2 = const Color(0xFF0E1521);
|
|
var colorPrimaryLight = const Color(0xFFF3F4F7);
|
|
var colorPrimaryLight2 = const Color(0xFFF5F6F9);
|
|
|
|
var colorAccent = const Color(0xFF3B82F6); // blue — web/deploy differentiation
|
|
var colorPositive = const Color(0xFF17C79A);
|
|
var colorNegative = const Color(0xFFE5484D);
|
|
var colorWarmYellow = const Color(0xFFF5A623);
|
|
var colorViolet = const Color(0xFF8B7CFF);
|
|
var colorCyan = const Color(0xFF38BDF8);
|
|
|
|
var colorGrey = const Color(0xFF94A3B8);
|
|
var colorGrey2 = const Color(0xFF586372);
|
|
var colorGrey3 = const Color(0xFF9AA3B0);
|
|
var colorBorder = const Color(0xFFE5E8ED);
|
|
var colorConsole = const Color(0xFF070A10);
|
|
|
|
/// The terminal palette. Console surfaces (log output, unit-file previews,
|
|
/// command blocks) stay dark in **both** app themes — like a code block, they
|
|
/// don't invert — so these are fixed tones rather than [JpColors] entries.
|
|
/// The background itself is still `jp.consoleBg`, which the light theme lifts
|
|
/// slightly.
|
|
class ConsoleColors {
|
|
const ConsoleColors._();
|
|
|
|
/// Hairline frame and dividers inside a console panel.
|
|
static const frame = Color(0xFF232B36);
|
|
|
|
/// Slightly lifted frame, for controls sitting on the console.
|
|
static const frameRaised = Color(0xFF2E3947);
|
|
|
|
/// Primary console body copy — the monospace output itself.
|
|
static const body = Color(0xFFD3D8E0);
|
|
|
|
/// Header/label copy and enabled control glyphs.
|
|
static const header = Color(0xFFB9C1CE);
|
|
|
|
/// Secondary counters and captions.
|
|
static const muted = Color(0xFF6B7482);
|
|
|
|
/// Disabled controls and placeholder copy.
|
|
static const disabled = Color(0xFF5A6472);
|
|
|
|
// Log severity tones, tuned for legibility on [JpColors.consoleBg].
|
|
static const error = Color(0xFFFF8B8B);
|
|
static const warn = Color(0xFFFFD066);
|
|
static const success = Color(0xFF3DE0AE);
|
|
static const step = Color(0xFF8AD0FF);
|
|
static const info = Color(0xFF9AA4B2);
|
|
|
|
/// The pulsing "LIVE" indicator on a streaming console.
|
|
static const live = Color(0xFFFF6B6B);
|
|
}
|
|
|
|
/// Builds the 50→900 swatch Material needs from a single brand colour.
|
|
MaterialColor createMaterialColor(Color color) {
|
|
final strengths = <double>[.05];
|
|
final swatch = <int, Color>{};
|
|
final int r = (color.r * 255).round(),
|
|
g = (color.g * 255).round(),
|
|
b = (color.b * 255).round();
|
|
|
|
for (int i = 1; i < 10; i++) {
|
|
strengths.add(0.1 * i);
|
|
}
|
|
for (final strength in strengths) {
|
|
final double ds = 0.5 - strength;
|
|
swatch[(strength * 1000).round()] = Color.fromRGBO(
|
|
r + ((ds < 0 ? r : (255 - r)) * ds).round(),
|
|
g + ((ds < 0 ? g : (255 - g)) * ds).round(),
|
|
b + ((ds < 0 ? b : (255 - b)) * ds).round(),
|
|
1,
|
|
);
|
|
}
|
|
return MaterialColor(color.toARGB32(), swatch);
|
|
}
|
|
|
|
/// `#RRGGBB` (or `#AARRGGBB`) → [Color].
|
|
Color getColorFromHex(String hex) {
|
|
var value = hex.replaceFirst('#', '');
|
|
if (value.length == 6) value = 'FF$value';
|
|
return Color(int.parse(value, radix: 16));
|
|
}
|
|
|
|
/// [Color] → `#AARRGGBB`.
|
|
String colorToHex(Color color) =>
|
|
'#${color.toARGB32().toRadixString(16).padLeft(8, '0').toUpperCase()}';
|
|
|
|
/// Brightness-aware neutral tokens (surfaces, borders, text). Brand accents
|
|
/// live on [PeachTheme] and read well on both light and dark.
|
|
@immutable
|
|
class JpColors extends ThemeExtension<JpColors> {
|
|
final Color bg;
|
|
final Color surface;
|
|
final Color surface2;
|
|
final Color border;
|
|
final Color text;
|
|
final Color textDim;
|
|
final Color textFaint;
|
|
final Color consoleBg;
|
|
|
|
const JpColors({
|
|
required this.bg,
|
|
required this.surface,
|
|
required this.surface2,
|
|
required this.border,
|
|
required this.text,
|
|
required this.textDim,
|
|
required this.textFaint,
|
|
required this.consoleBg,
|
|
});
|
|
|
|
// Deep, cool-neutral ink. More separation between each step so panels read
|
|
// crisply and hairline borders stay visible — the Linear/Vercel register.
|
|
static const dark = JpColors(
|
|
bg: Color(0xFF0A0E14),
|
|
surface: Color(0xFF121821),
|
|
surface2: Color(0xFF1B222D),
|
|
border: Color(0xFF283040),
|
|
text: Color(0xFFF3F5F8),
|
|
textDim: Color(0xFFAEB7C4),
|
|
textFaint: Color(0xFF6B7585),
|
|
consoleBg: Color(0xFF070A10),
|
|
);
|
|
|
|
// Clean, soft, premium light — off-white canvas, pure-white cards, crisp
|
|
// hairlines. Avoids the flat grey that reads "template".
|
|
static const light = JpColors(
|
|
bg: Color(0xFFF3F4F7),
|
|
surface: Color(0xFFFFFFFF),
|
|
surface2: Color(0xFFF5F6F9),
|
|
border: Color(0xFFE5E8ED),
|
|
text: Color(0xFF0E1521),
|
|
textDim: Color(0xFF586372),
|
|
textFaint: Color(0xFF9AA3B0),
|
|
consoleBg: Color(0xFF0E1521),
|
|
);
|
|
|
|
@override
|
|
JpColors copyWith({
|
|
Color? bg,
|
|
Color? surface,
|
|
Color? surface2,
|
|
Color? border,
|
|
Color? text,
|
|
Color? textDim,
|
|
Color? textFaint,
|
|
Color? consoleBg,
|
|
}) => JpColors(
|
|
bg: bg ?? this.bg,
|
|
surface: surface ?? this.surface,
|
|
surface2: surface2 ?? this.surface2,
|
|
border: border ?? this.border,
|
|
text: text ?? this.text,
|
|
textDim: textDim ?? this.textDim,
|
|
textFaint: textFaint ?? this.textFaint,
|
|
consoleBg: consoleBg ?? this.consoleBg,
|
|
);
|
|
|
|
@override
|
|
JpColors lerp(ThemeExtension<JpColors>? other, double t) {
|
|
if (other is! JpColors) return this;
|
|
return JpColors(
|
|
bg: Color.lerp(bg, other.bg, t)!,
|
|
surface: Color.lerp(surface, other.surface, t)!,
|
|
surface2: Color.lerp(surface2, other.surface2, t)!,
|
|
border: Color.lerp(border, other.border, t)!,
|
|
text: Color.lerp(text, other.text, t)!,
|
|
textDim: Color.lerp(textDim, other.textDim, t)!,
|
|
textFaint: Color.lerp(textFaint, other.textFaint, t)!,
|
|
consoleBg: Color.lerp(consoleBg, other.consoleBg, t)!,
|
|
);
|
|
}
|
|
}
|
|
|
|
extension JpColorsContext on BuildContext {
|
|
JpColors get jp => Theme.of(this).extension<JpColors>()!;
|
|
}
|
|
|
|
/// PeachDeployments theme: modern rounded "cockpit", light + dark.
|
|
class PeachTheme {
|
|
// "expand" brand palette: vermilion red-orange primary, ink dark, warm gray.
|
|
static const seed = Color(0xFFE8451F); // vermilion red-orange
|
|
static const ink = Color(0xFF111823); // near-black
|
|
static const accent = Color(0xFF3B82F6); // blue (web differentiation)
|
|
static const danger = Color(0xFFE5484D);
|
|
static const warn = Color(0xFFF5A623);
|
|
static const slate = Color(0xFF94A3B8); // neutral chart tone
|
|
static const violet = Color(0xFF8B7CFF);
|
|
static const mint = Color(0xFF17C79A); // fresh positive tone
|
|
static const cyan = Color(0xFF38BDF8);
|
|
static const pink = Color(0xFFEC4899); // completes the categorical set
|
|
|
|
static const _display = 'Sora';
|
|
static const _body = 'Urbanist';
|
|
|
|
// Warm brand gradient for CTAs, rings and progress.
|
|
static const brandGradient = LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [Color(0xFFFF6A3D), Color(0xFFE23C16)],
|
|
);
|
|
static const peachGradient = LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [Color(0xFFFFE0D0), Color(0xFFFFC1A3)],
|
|
);
|
|
|
|
static ThemeData light() => _build(Brightness.light, JpColors.light);
|
|
static ThemeData dark() => _build(Brightness.dark, JpColors.dark);
|
|
|
|
/// A colored glow (for accent elements).
|
|
static List<BoxShadow> glow(
|
|
Color c, {
|
|
double blur = 26,
|
|
double spread = -2,
|
|
double opacity = 0.45,
|
|
}) => [
|
|
BoxShadow(
|
|
color: c.withValues(alpha: opacity),
|
|
blurRadius: blur,
|
|
spreadRadius: spread,
|
|
offset: Offset(0, blur * 0.35),
|
|
),
|
|
];
|
|
|
|
/// Soft, layered depth shadow — barely-there but present, the premium
|
|
/// register (not the heavy drop shadow of template UIs).
|
|
static List<BoxShadow> softShadow(Brightness b) => b == Brightness.dark
|
|
? const [
|
|
BoxShadow(
|
|
color: Color(0x4D000000),
|
|
blurRadius: 28,
|
|
spreadRadius: -8,
|
|
offset: Offset(0, 18),
|
|
),
|
|
BoxShadow(
|
|
color: Color(0x26000000),
|
|
blurRadius: 4,
|
|
offset: Offset(0, 1),
|
|
),
|
|
]
|
|
: const [
|
|
BoxShadow(
|
|
color: Color(0x141B2A44),
|
|
blurRadius: 24,
|
|
spreadRadius: -6,
|
|
offset: Offset(0, 12),
|
|
),
|
|
BoxShadow(
|
|
color: Color(0x0A1B2A44),
|
|
blurRadius: 2,
|
|
offset: Offset(0, 1),
|
|
),
|
|
];
|
|
|
|
/// A hair-lighter tint of [surface] for the top edge of a card, giving an
|
|
/// opaque "lit from above" bevel without any translucency.
|
|
static Color _topSheen(Color surface, Brightness b) => b == Brightness.dark
|
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), surface)
|
|
: surface;
|
|
|
|
/// Elevated SOLID panel decoration used by the custom cards. Opaque, with a
|
|
/// subtle top-to-bottom sheen and a crisp hairline — no translucency.
|
|
static BoxDecoration panel(
|
|
BuildContext context, {
|
|
Color? glowColor,
|
|
double radius = 20,
|
|
}) {
|
|
final jp = context.jp;
|
|
final b = Theme.of(context).brightness;
|
|
return BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [_topSheen(jp.surface, b), jp.surface],
|
|
),
|
|
borderRadius: BorderRadius.circular(radius),
|
|
border: Border.all(color: jp.border),
|
|
boxShadow: [
|
|
...softShadow(b),
|
|
if (glowColor != null) ...glow(glowColor, opacity: 0.16, blur: 30),
|
|
],
|
|
);
|
|
}
|
|
|
|
static ThemeData _build(Brightness b, JpColors jp) {
|
|
final base = ThemeData(brightness: b, useMaterial3: true);
|
|
final scheme = ColorScheme.fromSeed(seedColor: seed, brightness: b)
|
|
.copyWith(
|
|
surface: jp.surface,
|
|
secondary: accent,
|
|
primary: seed,
|
|
onPrimary: Colors.white,
|
|
error: danger,
|
|
);
|
|
|
|
final textTheme = base.textTheme
|
|
.apply(fontFamily: _body, bodyColor: jp.text, displayColor: jp.text)
|
|
.copyWith(
|
|
// Tight, confident display tracking — the hallmark of premium type.
|
|
displaySmall: base.textTheme.displaySmall?.copyWith(
|
|
fontFamily: _display,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.8,
|
|
color: jp.text,
|
|
),
|
|
headlineMedium: base.textTheme.headlineMedium?.copyWith(
|
|
fontFamily: _display,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.6,
|
|
color: jp.text,
|
|
),
|
|
headlineSmall: base.textTheme.headlineSmall?.copyWith(
|
|
fontFamily: _display,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.4,
|
|
color: jp.text,
|
|
),
|
|
titleLarge: base.textTheme.titleLarge?.copyWith(
|
|
fontFamily: _display,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.2,
|
|
color: jp.text,
|
|
),
|
|
titleMedium: base.textTheme.titleMedium?.copyWith(
|
|
fontFamily: _display,
|
|
fontWeight: FontWeight.w700,
|
|
color: jp.text,
|
|
),
|
|
bodyLarge: base.textTheme.bodyLarge?.copyWith(
|
|
height: 1.45,
|
|
color: jp.text,
|
|
),
|
|
bodyMedium: base.textTheme.bodyMedium?.copyWith(
|
|
height: 1.45,
|
|
color: jp.textDim,
|
|
),
|
|
labelLarge: base.textTheme.labelLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.2,
|
|
),
|
|
);
|
|
|
|
return base.copyWith(
|
|
colorScheme: scheme,
|
|
extensions: [jp],
|
|
// Transparent so the glow backdrop shows through app-wide.
|
|
scaffoldBackgroundColor: Colors.transparent,
|
|
canvasColor: jp.bg,
|
|
textTheme: textTheme,
|
|
splashFactory: InkSparkle.splashFactory,
|
|
appBarTheme: AppBarTheme(
|
|
backgroundColor: jp.bg,
|
|
foregroundColor: jp.text,
|
|
surfaceTintColor: Colors.transparent,
|
|
elevation: 0,
|
|
scrolledUnderElevation: 0,
|
|
centerTitle: false,
|
|
titleTextStyle: TextStyle(
|
|
fontFamily: _display,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.3,
|
|
color: jp.text,
|
|
),
|
|
iconTheme: IconThemeData(color: jp.textDim),
|
|
),
|
|
iconTheme: IconThemeData(color: jp.textDim),
|
|
dividerTheme: DividerThemeData(color: jp.border, thickness: 1),
|
|
listTileTheme: ListTileThemeData(
|
|
iconColor: jp.textDim,
|
|
textColor: jp.text,
|
|
),
|
|
tabBarTheme: TabBarThemeData(
|
|
labelColor: seed,
|
|
unselectedLabelColor: jp.textFaint,
|
|
indicatorColor: seed,
|
|
indicatorSize: TabBarIndicatorSize.label,
|
|
dividerColor: jp.border,
|
|
overlayColor: WidgetStateProperty.all(seed.withValues(alpha: 0.06)),
|
|
),
|
|
popupMenuTheme: PopupMenuThemeData(
|
|
color: jp.surface,
|
|
surfaceTintColor: Colors.transparent,
|
|
elevation: 12,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(color: jp.border),
|
|
),
|
|
),
|
|
dialogTheme: DialogThemeData(
|
|
backgroundColor: jp.surface,
|
|
surfaceTintColor: Colors.transparent,
|
|
elevation: 24,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
side: BorderSide(color: jp.border),
|
|
),
|
|
),
|
|
snackBarTheme: SnackBarThemeData(
|
|
behavior: SnackBarBehavior.floating,
|
|
backgroundColor: b == Brightness.dark
|
|
? jp.surface2
|
|
: const Color(0xFF161D28),
|
|
contentTextStyle: const TextStyle(
|
|
color: Colors.white,
|
|
fontFamily: _body,
|
|
),
|
|
elevation: 12,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(
|
|
color: b == Brightness.dark ? jp.border : Colors.transparent,
|
|
),
|
|
),
|
|
),
|
|
cardTheme: CardThemeData(
|
|
color: jp.surface,
|
|
elevation: 0,
|
|
shadowColor: Colors.transparent,
|
|
surfaceTintColor: Colors.transparent,
|
|
margin: EdgeInsets.zero,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
side: BorderSide(color: jp.border),
|
|
),
|
|
),
|
|
inputDecorationTheme: InputDecorationTheme(
|
|
filled: true,
|
|
fillColor: jp.surface2,
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 14,
|
|
vertical: 14,
|
|
),
|
|
hintStyle: TextStyle(color: jp.textFaint),
|
|
labelStyle: TextStyle(color: jp.textDim),
|
|
floatingLabelStyle: const TextStyle(
|
|
color: seed,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
prefixIconColor: jp.textFaint,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(13),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(13),
|
|
borderSide: BorderSide(color: jp.border),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(13),
|
|
borderSide: const BorderSide(color: seed, width: 1.6),
|
|
),
|
|
),
|
|
filledButtonTheme: FilledButtonThemeData(
|
|
style:
|
|
FilledButton.styleFrom(
|
|
backgroundColor: seed,
|
|
foregroundColor: Colors.white,
|
|
disabledBackgroundColor: seed.withValues(alpha: 0.32),
|
|
disabledForegroundColor: Colors.white70,
|
|
elevation: 0,
|
|
shadowColor: Colors.transparent,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(13),
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
|
textStyle: const TextStyle(
|
|
fontFamily: _body,
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 14,
|
|
letterSpacing: 0.2,
|
|
),
|
|
).copyWith(
|
|
// Subtle brand shadow only when the button is actually enabled.
|
|
elevation: WidgetStateProperty.resolveWith(
|
|
(s) => s.contains(WidgetState.disabled) ? 0 : 6,
|
|
),
|
|
shadowColor: WidgetStateProperty.all(
|
|
seed.withValues(alpha: 0.45),
|
|
),
|
|
overlayColor: WidgetStateProperty.all(
|
|
Colors.white.withValues(alpha: 0.10),
|
|
),
|
|
),
|
|
),
|
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: jp.text,
|
|
side: BorderSide(color: jp.border),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(13),
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 15),
|
|
textStyle: const TextStyle(
|
|
fontFamily: _body,
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
),
|
|
textButtonTheme: TextButtonThemeData(
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: seed,
|
|
textStyle: const TextStyle(
|
|
fontFamily: _body,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
|
backgroundColor: seed,
|
|
foregroundColor: Colors.white,
|
|
elevation: 8,
|
|
highlightElevation: 10,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
),
|
|
chipTheme: base.chipTheme.copyWith(
|
|
backgroundColor: jp.surface2,
|
|
side: BorderSide(color: jp.border),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(9)),
|
|
labelStyle: TextStyle(color: jp.textDim, fontWeight: FontWeight.w600),
|
|
),
|
|
switchTheme: SwitchThemeData(
|
|
thumbColor: WidgetStateProperty.resolveWith(
|
|
(s) => s.contains(WidgetState.selected) ? seed : null,
|
|
),
|
|
trackColor: WidgetStateProperty.resolveWith(
|
|
(s) => s.contains(WidgetState.selected)
|
|
? seed.withValues(alpha: 0.4)
|
|
: null,
|
|
),
|
|
),
|
|
progressIndicatorTheme: ProgressIndicatorThemeData(
|
|
color: seed,
|
|
linearTrackColor: jp.surface2,
|
|
circularTrackColor: Colors.transparent,
|
|
),
|
|
tooltipTheme: TooltipThemeData(
|
|
decoration: BoxDecoration(
|
|
color: b == Brightness.dark ? jp.surface2 : const Color(0xFF161D28),
|
|
borderRadius: BorderRadius.circular(9),
|
|
border: Border.all(
|
|
color: b == Brightness.dark ? jp.border : Colors.transparent,
|
|
),
|
|
),
|
|
textStyle: const TextStyle(
|
|
color: Colors.white,
|
|
fontFamily: _body,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|