initial commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>()!;
|
||||
}
|
||||
Reference in New Issue
Block a user