Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
569
lib/Peach/utils/Colors.dart
Normal file
569
lib/Peach/utils/Colors.dart
Normal file
@@ -0,0 +1,569 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
69
lib/Peach/utils/CommonUtils.dart
Normal file
69
lib/Peach/utils/CommonUtils.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
/// Shared formatting helpers. Anything a screen would otherwise write inline
|
||||
/// to render a value belongs here, so the same quantity reads the same way
|
||||
/// everywhere in the app.
|
||||
library;
|
||||
|
||||
/// Human-friendly "time ago" formatting.
|
||||
String timeAgo(DateTime t) {
|
||||
final d = DateTime.now().difference(t);
|
||||
if (d.inSeconds < 45) return 'just now';
|
||||
if (d.inMinutes < 60) return '${d.inMinutes}m ago';
|
||||
if (d.inHours < 24) return '${d.inHours}h ago';
|
||||
if (d.inDays < 7) return '${d.inDays}d ago';
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
return '${t.year}-${two(t.month)}-${two(t.day)}';
|
||||
}
|
||||
|
||||
/// Byte count as B / KB / MB / GB.
|
||||
String formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||
}
|
||||
|
||||
/// An integer with thousands separators — offsets and message counts are
|
||||
/// unreadable without them.
|
||||
String formatCount(int value) {
|
||||
final digits = value.abs().toString();
|
||||
final out = StringBuffer(value < 0 ? '-' : '');
|
||||
for (var i = 0; i < digits.length; i++) {
|
||||
if (i > 0 && (digits.length - i) % 3 == 0) out.write(',');
|
||||
out.write(digits[i]);
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/// `HH:MM` for a timestamp.
|
||||
String formatClock(DateTime t) {
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
return '${two(t.hour)}:${two(t.minute)}';
|
||||
}
|
||||
|
||||
/// The last segment of a path, accepting either separator and ignoring a
|
||||
/// trailing slash.
|
||||
String baseName(String path) {
|
||||
final normalised = path.replaceAll('\\', '/').replaceAll(RegExp(r'/+$'), '');
|
||||
final i = normalised.lastIndexOf('/');
|
||||
return i >= 0 ? normalised.substring(i + 1) : normalised;
|
||||
}
|
||||
|
||||
/// Joins a POSIX directory and a child name without doubling the separator.
|
||||
String joinRemote(String base, String name) {
|
||||
final trimmed = base.endsWith('/')
|
||||
? base.substring(0, base.length - 1)
|
||||
: base;
|
||||
return '$trimmed/$name';
|
||||
}
|
||||
|
||||
/// The parent of a POSIX path, bottoming out at `/`.
|
||||
String parentRemote(String path) {
|
||||
final trimmed = path.endsWith('/') && path.length > 1
|
||||
? path.substring(0, path.length - 1)
|
||||
: path;
|
||||
final i = trimmed.lastIndexOf('/');
|
||||
if (i <= 0) return '/';
|
||||
return trimmed.substring(0, i);
|
||||
}
|
||||
26
lib/Peach/utils/Validators.dart
Normal file
26
lib/Peach/utils/Validators.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
// Form validators. Screens reference these rather than writing closures
|
||||
// inline, so an error message reads the same wherever the rule applies.
|
||||
|
||||
String? requiredField(String? value) =>
|
||||
(value == null || value.trim().isEmpty) ? 'Required' : null;
|
||||
|
||||
String? validPort(String? value) {
|
||||
if (value == null || value.trim().isEmpty) return null; // falls back to 22
|
||||
final port = int.tryParse(value.trim());
|
||||
if (port == null) return 'Numbers only';
|
||||
if (port < 1 || port > 65535) return 'Must be 1–65535';
|
||||
return null;
|
||||
}
|
||||
|
||||
String? validHost(String? value) {
|
||||
if (value == null || value.trim().isEmpty) return 'Required';
|
||||
if (value.trim().contains(' ')) return 'No spaces allowed';
|
||||
return null;
|
||||
}
|
||||
|
||||
/// An absolute POSIX path, as every remote target must be.
|
||||
String? validRemotePath(String? value) {
|
||||
if (value == null || value.trim().isEmpty) return 'Required';
|
||||
if (!value.trim().startsWith('/')) return 'Must start with /';
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user