initial commit

This commit is contained in:
2026-07-27 14:42:23 +02:00
commit 833c68a189
257 changed files with 17947 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
/// Exact hex values from the approved design mockup. Hand-authored rather
/// than derived from [ColorScheme.fromSeed] so these values can't drift.
abstract final class AppColors {
// Light theme
static const lightBg = Color(0xFFF2F6F6);
static const lightSurface = Color(0xFFFFFFFF);
static const lightSurfaceAlt = Color(0xFFE9EFEF);
static const lightBorder = Color(0xFFDAE3E4);
static const lightText = Color(0xFF0E1B1D);
static const lightTextMuted = Color(0xFF47585B);
static const lightTextFaint = Color(0xFF7C9195);
static const lightAccent = Color(0xFF0E8E92);
static const lightAccentStrong = Color(0xFF0A7377);
static const lightAccentInk = Color(0xFFFFFFFF);
// Dark theme
static const darkBg = Color(0xFF0C1315);
static const darkSurface = Color(0xFF121B1D);
static const darkSurfaceAlt = Color(0xFF182224);
static const darkBorder = Color(0xFF223032);
static const darkText = Color(0xFFE8F0F0);
static const darkTextMuted = Color(0xFFA9BFC2);
static const darkTextFaint = Color(0xFF6C8386);
static const darkAccent = Color(0xFF2FC6CA);
static const darkAccentStrong = Color(0xFF55D8DB);
static const darkAccentInk = Color(0xFF06282A);
// Status — semantic, deliberately distinct from the accent hue, same
// across themes except `warning`, which is re-tuned per theme below to
// hold contrast against each surface.
static const good = Color(0xFF0CA30C);
static const warningLight = Color(0xFFC8890A);
static const warningDark = Color(0xFFE3A82B);
static const serious = Color(0xFFEC835A);
static const critical = Color(0xFFD03B3B);
}
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
/// Typography for "technical values" (compose paths, hostnames, ids,
/// timestamps) and for tabular numerals. These are two distinct rules from
/// the design spec, not one:
/// - `dataMono*` swaps the font *family* to JetBrains Mono.
/// - `numericTabular` keeps the UI sans (Inter) but turns on the
/// `tnum` font feature so digits line up in columns (stat tiles,
/// numeric table cells) without looking like a code snippet.
@immutable
class AppDataStyles extends ThemeExtension<AppDataStyles> {
const AppDataStyles({
required this.dataMono,
required this.dataMonoSmall,
required this.numericTabular,
});
final TextStyle dataMono;
final TextStyle dataMonoSmall;
final TextStyle numericTabular;
static AppDataStyles resolve({required Color textColor, required Color mutedColor}) {
return AppDataStyles(
dataMono: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 13,
color: mutedColor,
height: 1.3,
),
dataMonoSmall: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11.5,
color: mutedColor,
height: 1.3,
),
numericTabular: TextStyle(
fontFamily: 'Inter',
color: textColor,
fontFeatures: const [FontFeature.tabularFigures()],
),
);
}
@override
AppDataStyles copyWith({
TextStyle? dataMono,
TextStyle? dataMonoSmall,
TextStyle? numericTabular,
}) {
return AppDataStyles(
dataMono: dataMono ?? this.dataMono,
dataMonoSmall: dataMonoSmall ?? this.dataMonoSmall,
numericTabular: numericTabular ?? this.numericTabular,
);
}
@override
AppDataStyles lerp(ThemeExtension<AppDataStyles>? other, double t) {
if (other is! AppDataStyles) return this;
return AppDataStyles(
dataMono: TextStyle.lerp(dataMono, other.dataMono, t)!,
dataMonoSmall: TextStyle.lerp(dataMonoSmall, other.dataMonoSmall, t)!,
numericTabular: TextStyle.lerp(numericTabular, other.numericTabular, t)!,
);
}
}
+146
View File
@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
import 'app_data_styles.dart';
import 'status_colors.dart';
/// Builds the app's light/dark [ThemeData]. Colors are constructed
/// explicitly from [AppColors] rather than via [ColorScheme.fromSeed] so the
/// approved mockup's exact hex values can't drift through algorithmic tone
/// generation.
abstract final class AppTheme {
static ThemeData light() => _build(brightness: Brightness.light);
static ThemeData dark() => _build(brightness: Brightness.dark);
static ThemeData _build({required Brightness brightness}) {
final isDark = brightness == Brightness.dark;
final colorScheme = isDark
? const ColorScheme.dark(
surface: AppColors.darkBg,
onSurface: AppColors.darkText,
surfaceContainerHighest: AppColors.darkSurfaceAlt,
primary: AppColors.darkAccent,
onPrimary: AppColors.darkAccentInk,
secondary: AppColors.darkAccentStrong,
onSecondary: AppColors.darkAccentInk,
error: AppColors.critical,
onError: Colors.white,
outline: AppColors.darkBorder,
outlineVariant: AppColors.darkBorder,
)
: const ColorScheme.light(
surface: AppColors.lightBg,
onSurface: AppColors.lightText,
surfaceContainerHighest: AppColors.lightSurfaceAlt,
primary: AppColors.lightAccent,
onPrimary: AppColors.lightAccentInk,
secondary: AppColors.lightAccentStrong,
onSecondary: AppColors.lightAccentInk,
error: AppColors.critical,
onError: Colors.white,
outline: AppColors.lightBorder,
outlineVariant: AppColors.lightBorder,
);
final surfaceCard = isDark ? AppColors.darkSurface : AppColors.lightSurface;
final textColor = isDark ? AppColors.darkText : AppColors.lightText;
final textMuted = isDark ? AppColors.darkTextMuted : AppColors.lightTextMuted;
final textFaint = isDark ? AppColors.darkTextFaint : AppColors.lightTextFaint;
final border = isDark ? AppColors.darkBorder : AppColors.lightBorder;
final textTheme = TextTheme(
displaySmall: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 30, color: textColor, letterSpacing: -0.2),
headlineSmall: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 22, color: textColor, letterSpacing: -0.1),
titleLarge: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 17, color: textColor, letterSpacing: -0.1),
titleMedium: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, fontSize: 14, color: textColor),
titleSmall: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 11, color: textFaint, letterSpacing: 0.6),
bodyLarge: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w400, fontSize: 15, color: textColor, height: 1.5),
bodyMedium: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w400, fontSize: 13.5, color: textColor, height: 1.45),
bodySmall: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w500, fontSize: 12, color: textMuted),
labelLarge: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, fontSize: 13, color: textColor),
labelMedium: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, fontSize: 12, color: textMuted),
labelSmall: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 11, color: textFaint, letterSpacing: 0.5),
);
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: colorScheme,
scaffoldBackgroundColor: colorScheme.surface,
canvasColor: colorScheme.surface,
dividerColor: border,
fontFamily: 'Inter',
textTheme: textTheme,
cardTheme: CardThemeData(
color: surfaceCard,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: border),
),
),
appBarTheme: AppBarTheme(
backgroundColor: colorScheme.surface,
foregroundColor: textColor,
elevation: 0,
surfaceTintColor: Colors.transparent,
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: isDark ? AppColors.darkSurfaceAlt : AppColors.lightSurfaceAlt,
indicatorColor: colorScheme.primary.withValues(alpha: 0.16),
selectedIconTheme: IconThemeData(color: colorScheme.secondary),
unselectedIconTheme: IconThemeData(color: textMuted),
selectedLabelTextStyle: TextStyle(color: colorScheme.secondary, fontWeight: FontWeight.w700, fontSize: 12.5),
unselectedLabelTextStyle: TextStyle(color: textMuted, fontWeight: FontWeight.w500, fontSize: 12.5),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)),
textStyle: const TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, fontSize: 13),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: textColor,
side: BorderSide(color: border),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)),
textStyle: const TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, fontSize: 13),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(7),
borderSide: BorderSide(color: border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(7),
borderSide: BorderSide(color: border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(7),
borderSide: BorderSide(color: colorScheme.primary, width: 1.5),
),
labelStyle: TextStyle(color: textMuted, fontSize: 12.5, fontWeight: FontWeight.w600),
),
dataTableTheme: DataTableThemeData(
headingTextStyle: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 11, color: textFaint, letterSpacing: 0.5),
dataTextStyle: TextStyle(fontFamily: 'Inter', fontSize: 13, color: textColor),
dividerThickness: 1,
),
extensions: [
isDark ? StatusColors.dark : StatusColors.light,
AppDataStyles.resolve(textColor: textColor, mutedColor: textMuted),
],
);
}
}
+16
View File
@@ -0,0 +1,16 @@
/// Spacing scale used across the app. Plain constants rather than a
/// [ThemeExtension] — spacing doesn't change between light/dark, so there's
/// no lerp behavior to gain from routing it through the theme mechanism.
abstract final class Spacing {
static const xs = 4.0;
static const sm = 8.0;
static const md = 12.0;
static const lg = 16.0;
static const xl = 20.0;
static const xxl = 28.0;
static const xxxl = 40.0;
static const radiusSm = 6.0;
static const radiusMd = 8.0;
static const radiusLg = 10.0;
}
+82
View File
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
/// Semantic status colors (good/warning/serious/critical) used for service
/// up/down/unknown pills, backup/update success/fail pills, and stat-tile
/// severity stripes. Kept out of [ColorScheme] because Material only has a
/// single `error` role and these four are a distinct, reserved set that
/// must never be reused for anything else (e.g. a 4th categorical series).
@immutable
class StatusColors extends ThemeExtension<StatusColors> {
const StatusColors({
required this.good,
required this.warning,
required this.serious,
required this.critical,
});
final Color good;
final Color warning;
final Color serious;
final Color critical;
static const light = StatusColors(
good: AppColors.good,
warning: AppColors.warningLight,
serious: AppColors.serious,
critical: AppColors.critical,
);
static const dark = StatusColors(
good: AppColors.good,
warning: AppColors.warningDark,
serious: AppColors.serious,
critical: AppColors.critical,
);
@override
StatusColors copyWith({
Color? good,
Color? warning,
Color? serious,
Color? critical,
}) {
return StatusColors(
good: good ?? this.good,
warning: warning ?? this.warning,
serious: serious ?? this.serious,
critical: critical ?? this.critical,
);
}
@override
StatusColors lerp(ThemeExtension<StatusColors>? other, double t) {
if (other is! StatusColors) return this;
return StatusColors(
good: Color.lerp(good, other.good, t)!,
warning: Color.lerp(warning, other.warning, t)!,
serious: Color.lerp(serious, other.serious, t)!,
critical: Color.lerp(critical, other.critical, t)!,
);
}
}
/// The four states a service / backup execution / update record / remote
/// node can render as a [StatusPill]. Kept as one enum shared across
/// features rather than per-feature status strings, so pill color mapping
/// lives in exactly one place.
enum Severity { good, warning, serious, critical, neutral }
extension SeverityColor on Severity {
/// Resolves to a status color, or [muted] for [Severity.neutral] (which
/// has no reserved status hue — callers pass their theme's muted/outline
/// color for the "no data" / "not configured" case).
Color resolve(StatusColors colors, {required Color muted}) => switch (this) {
Severity.good => colors.good,
Severity.warning => colors.warning,
Severity.serious => colors.serious,
Severity.critical => colors.critical,
Severity.neutral => muted,
};
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../utils/shared_preferences_provider.dart';
part 'theme_mode_provider.g.dart';
const _themeModeKey = 'theme_mode';
@Riverpod(keepAlive: true)
class ThemeModeController extends _$ThemeModeController {
@override
ThemeMode build() {
final stored = ref.watch(sharedPreferencesProvider).getString(_themeModeKey);
return switch (stored) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};
}
Future<void> setThemeMode(ThemeMode mode) async {
state = mode;
await ref.read(sharedPreferencesProvider).setString(_themeModeKey, mode.name);
}
}
+64
View File
@@ -0,0 +1,64 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'theme_mode_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ThemeModeController)
const themeModeControllerProvider = ThemeModeControllerProvider._();
final class ThemeModeControllerProvider
extends $NotifierProvider<ThemeModeController, ThemeMode> {
const ThemeModeControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'themeModeControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$themeModeControllerHash();
@$internal
@override
ThemeModeController create() => ThemeModeController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ThemeMode value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ThemeMode>(value),
);
}
}
String _$themeModeControllerHash() =>
r'521a0916c167062954c4b944c327fbeca64c294c';
abstract class _$ThemeModeController extends $Notifier<ThemeMode> {
ThemeMode build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<ThemeMode, ThemeMode>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ThemeMode, ThemeMode>,
ThemeMode,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
import 'app_data_styles.dart';
import 'status_colors.dart';
/// Shorthand accessors so screens read `context.status.good` /
/// `context.dataStyles.dataMono` instead of the verbose
/// `Theme.of(context).extension<...>()!` at every call site.
extension ThemeX on BuildContext {
ThemeData get theme => Theme.of(this);
ColorScheme get colors => Theme.of(this).colorScheme;
TextTheme get text => Theme.of(this).textTheme;
StatusColors get status => Theme.of(this).extension<StatusColors>()!;
AppDataStyles get dataStyles => Theme.of(this).extension<AppDataStyles>()!;
}