From 6d0c3d9c812590a85bf568c558cf68ef4c0bf725 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:59:22 +0000 Subject: [PATCH 1/6] Add Hermes design tokens and wire them into the theme The app had no ColorScheme in either theme -- only primaryColor was set -- so every Material 3 component fell back to Flutter's defaults instead of the app's colors. Text styles hardcoded black or white, which is why a second *White variant existed for every heading. Introduce HermesTokens, a ThemeExtension carrying the palette ported from hermes-webui's stylesheet (warm gold on cream, gold on navy-black), plus the radius and spacing scale. Both themes are now built from one function, so light and dark cannot drift apart. Five of the shipped light values fail WCAG AA as text on cream -- accent text lands at 4.31:1 on the strong tint, warning at 2.56:1, and white on gold at 3.25:1. Gold is a light hue and needs two or three shade steps down before it clears AA. The text-bearing colors are darkened accordingly and a dedicated onAccent ink replaces white-on-accent; the hues are unchanged. accessibleOn() walks the lightness axis to keep that guarantee when a distribution color is substituted. Also: - MyApp becomes stateful and listens to a ThemeController, so switching light/dark no longer requires restarting the app. - Per-distribution colors become opt-in (use_distro_colors, default off) now that the app ships a palette of its own. - colorfulBackground changes from a static field to a getter; as a field it captured currentColor once at class-load time and kept painting the old gradient after a color change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HioAo2j6ufA9GsyKBWF69u --- lib/layouts/hermes_tokens.dart | 389 +++++++++++++++++++++++++++++ lib/layouts/mint_y.dart | 226 +++++++++++------ lib/main.dart | 127 +++++++--- lib/services/theme_controller.dart | 131 ++++++++++ 4 files changed, 768 insertions(+), 105 deletions(-) create mode 100644 lib/layouts/hermes_tokens.dart create mode 100644 lib/services/theme_controller.dart diff --git a/lib/layouts/hermes_tokens.dart b/lib/layouts/hermes_tokens.dart new file mode 100644 index 0000000..0e89cd1 --- /dev/null +++ b/lib/layouts/hermes_tokens.dart @@ -0,0 +1,389 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// Design tokens ported from the Hermes WebUI stylesheet. +/// +/// Hermes expresses its whole visual language through CSS custom properties on +/// `:root` (light) and `:root.dark` (dark). This class is the Flutter +/// equivalent: one immutable bundle of colors per brightness, reachable from +/// any widget through `HermesTokens.of(context)`. +/// +/// Three rules carry most of the look and are worth keeping in mind when +/// building new widgets against these tokens: +/// +/// * Elevation comes from a 1px [border] plus a tint, not from a shadow. +/// * Every tinted chip, badge, active row and secondary button is built from +/// exactly [accentBg] (8%), [accentBgStrong] (15%) and [accentText] (solid). +/// * Metadata is de-emphasized with opacity, not with a different color. +@immutable +class HermesTokens extends ThemeExtension { + // Surfaces. + final Color bg; + final Color sidebar; + final Color surface; + final Color surfaceSubtle; + final Color surfaceSubtleHover; + + // Borders. [border] is the visible hairline, [borderMuted] the quieter one + // used for the 2px spine on cards. + final Color border; + final Color borderMuted; + final Color borderSubtle; + + // Text. [strong] is for headings, [text] for body, [muted] for metadata. + final Color text; + final Color strong; + final Color muted; + + // Accent triad plus its two tints. + final Color accent; + final Color accentHover; + final Color accentText; + final Color accentBg; + final Color accentBgStrong; + + /// Ink to place on top of a solid [accent] fill. + /// + /// Gold is a light hue, so white-on-gold fails AA in light mode (3.25:1). + /// Both brightnesses therefore use a dark ink here rather than the usual + /// "white text on the brand color". + final Color onAccent; + + // Semantic colors. These brighten in dark mode rather than staying fixed. + final Color error; + final Color success; + final Color warning; + final Color info; + + // Interaction. + final Color hoverBg; + final Color inputBg; + final Color focusRing; + + // Code / monospace surfaces. + final Color codeBg; + final Color codeText; + + const HermesTokens({ + required this.bg, + required this.sidebar, + required this.surface, + required this.surfaceSubtle, + required this.surfaceSubtleHover, + required this.border, + required this.borderMuted, + required this.borderSubtle, + required this.text, + required this.strong, + required this.muted, + required this.accent, + required this.accentHover, + required this.accentText, + required this.accentBg, + required this.accentBgStrong, + required this.onAccent, + required this.error, + required this.success, + required this.warning, + required this.info, + required this.hoverBg, + required this.inputBg, + required this.focusRing, + required this.codeBg, + required this.codeText, + }); + + /// Hermes default skin, light. Warm gold on cream. + static const HermesTokens light = HermesTokens( + bg: Color(0xFFFEFCF7), + sidebar: Color(0xFFFAF7F0), + surface: Color(0xFFF3EEE3), + surfaceSubtle: Color(0xFFF7F4EC), + surfaceSubtleHover: Color(0xFFEFEADF), + border: Color(0xFFE0D8C8), + borderMuted: Color(0xFFD0C6B2), + borderSubtle: Color(0xFFEAE4D8), + text: Color(0xFF1A1610), + strong: Color(0xFF0F0D08), + muted: Color(0xFF5C5344), + accent: Color(0xFFB8860B), + accentHover: Color(0xFF996F08), + // Hermes ships #8B6508 here, which lands at 4.31:1 on accentBgStrong. + // Two shades darker clears AA on every surface in this palette. + accentText: Color(0xFF7F5C08), + // accent @ 8% and @ 15% pre-composited over [bg]; Flutter has no + // color-mix(), and pre-blending keeps these usable as opaque fills. + accentBg: Color(0xFFF8F2E4), + accentBgStrong: Color(0xFFF1E7CE), + onAccent: Color(0xFF1A1610), + error: Color(0xFFC62828), + // Hermes' light success/warning/info are tuned for large UI chrome and + // fail AA as body text on cream (4.12 / 2.56 / 4.02). Darkened here so + // they stay legible as text; the hues are unchanged. + success: Color(0xFF2E7D32), + warning: Color(0xFFB45309), + info: Color(0xFF05748F), + hoverBg: Color(0x0D000000), + inputBg: Color(0x08000000), + focusRing: Color(0x59B8860B), + codeBg: Color(0xFFF5F0E5), + codeText: Color(0xFF8B4513), + ); + + /// Hermes default skin, dark. Gold on navy-black. + static const HermesTokens dark = HermesTokens( + bg: Color(0xFF0D0D1A), + sidebar: Color(0xFF141425), + surface: Color(0xFF1A1A2E), + surfaceSubtle: Color(0xFF16162A), + surfaceSubtleHover: Color(0xFF1F1F35), + border: Color(0xFF2A2A45), + borderMuted: Color(0xFF3A3A58), + borderSubtle: Color(0xFF20203A), + text: Color(0xFFFFF8DC), + strong: Color(0xFFFFFFFF), + muted: Color(0xFFC0C0C0), + accent: Color(0xFFFFD700), + accentHover: Color(0xFFFFBF00), + accentText: Color(0xFFFFD700), + accentBg: Color(0xFF201D18), + accentBgStrong: Color(0xFF322D1D), + onAccent: Color(0xFF0D0D1A), + error: Color(0xFFEF5350), + success: Color(0xFF4CAF50), + warning: Color(0xFFFFA726), + info: Color(0xFF4DD0E1), + hoverBg: Color(0x0FFFFFFF), + inputBg: Color(0x0AFFFFFF), + focusRing: Color(0x59FFD700), + codeBg: Color(0xFF1A1A2E), + codeText: Color(0xFFF0C27F), + ); + + /// Reads the tokens for the current theme. + /// + /// Falls back to the palette matching the ambient brightness so that widgets + /// still render sensibly inside a bare `MaterialApp` (notably in widget + /// tests, which do not go through `MintY.theme()`). + static HermesTokens of(BuildContext context) { + final theme = Theme.of(context); + return theme.extension() ?? + (theme.brightness == Brightness.dark ? dark : light); + } + + // --- Structural tokens ----------------------------------------------- + // Not part of the lerped state: these never animate. + + static const double radiusSm = 4; + static const double radiusMd = 8; + static const double radiusLg = 12; + static const double radiusPill = 999; + + static const double space1 = 4; + static const double space2 = 8; + static const double space3 = 12; + static const double space4 = 16; + + /// Hairline border width. Hermes uses 1px everywhere and almost no shadows. + static const double borderWidth = 1; + + /// Width of the accent spine on active nav items and tool cards. + static const double spineWidth = 2; + + /// Opacity steps used to express hierarchy in metadata rows. + static const double opacityFaint = 0.42; + static const double opacityMuted = 0.56; + static const double opacityStrong = 0.75; + + static const String fontMono = 'monospace'; + + // --- Contrast helpers ------------------------------------------------- + + /// Relative luminance per WCAG 2.2. + static double relativeLuminance(Color color) { + // Color.r/g/b are already normalized to 0..1. + double channel(double c) { + return c <= 0.03928 + ? c / 12.92 + : math.pow((c + 0.055) / 1.055, 2.4).toDouble(); + } + + return 0.2126 * channel(color.r) + + 0.7152 * channel(color.g) + + 0.0722 * channel(color.b); + } + + /// Contrast ratio between two opaque colors, from 1.0 to 21.0. + static double contrastRatio(Color a, Color b) { + final la = relativeLuminance(a); + final lb = relativeLuminance(b); + final lighter = math.max(la, lb); + final darker = math.min(la, lb); + return (lighter + 0.05) / (darker + 0.05); + } + + /// Returns [color] stepped toward black or white until it reaches [target] + /// contrast against [background]. + /// + /// Mid-brightness brand hues — gold, orange, teal — routinely land around + /// 3:1 as text and need two or three shade steps before they clear AA. This + /// walks the lightness axis instead of guessing a shade number. + static Color accessibleOn( + Color color, + Color background, { + double target = 4.5, + }) { + if (contrastRatio(color, background) >= target) { + return color; + } + + // Decide direction once: on a light background go darker, on a dark one go + // lighter. Stepping the wrong way can never reach the target. + final towardBlack = relativeLuminance(background) > 0.18; + final hsl = HSLColor.fromColor(color); + + for (int step = 1; step <= 20; step++) { + final factor = step / 20.0; + final lightness = towardBlack + ? hsl.lightness * (1 - factor) + : hsl.lightness + (1 - hsl.lightness) * factor; + final candidate = + hsl.withLightness(lightness.clamp(0.0, 1.0)).toColor(); + if (contrastRatio(candidate, background) >= target) { + return candidate; + } + } + + return towardBlack ? const Color(0xFF000000) : const Color(0xFFFFFFFF); + } + + /// Rebuilds the accent family around [newAccent], keeping every derived tone + /// accessible. + /// + /// Used when the user opts into distribution-specific colors: only the accent + /// is swapped, and the tints, accent text and button ink are recomputed so + /// the palette cannot silently fall below AA. + HermesTokens withAccent(Color newAccent) { + Color tint(double alpha) => + Color.alphaBlend(newAccent.withValues(alpha: alpha), bg); + + final newAccentBg = tint(0.08); + final newAccentBgStrong = tint(0.15); + + return copyWith( + accent: newAccent, + accentHover: HSLColor.fromColor(newAccent) + .withLightness( + (HSLColor.fromColor(newAccent).lightness * 0.85).clamp(0.0, 1.0)) + .toColor(), + // Must stay readable on the strongest tint, which is the tightest of the + // three surfaces it appears on. + accentText: accessibleOn(newAccent, newAccentBgStrong), + accentBg: newAccentBg, + accentBgStrong: newAccentBgStrong, + onAccent: accessibleOn( + contrastRatio(const Color(0xFFFFFFFF), newAccent) >= + contrastRatio(text, newAccent) + ? const Color(0xFFFFFFFF) + : text, + newAccent, + ), + ); + } + + @override + HermesTokens copyWith({ + Color? bg, + Color? sidebar, + Color? surface, + Color? surfaceSubtle, + Color? surfaceSubtleHover, + Color? border, + Color? borderMuted, + Color? borderSubtle, + Color? text, + Color? strong, + Color? muted, + Color? accent, + Color? accentHover, + Color? accentText, + Color? accentBg, + Color? accentBgStrong, + Color? onAccent, + Color? error, + Color? success, + Color? warning, + Color? info, + Color? hoverBg, + Color? inputBg, + Color? focusRing, + Color? codeBg, + Color? codeText, + }) { + return HermesTokens( + bg: bg ?? this.bg, + sidebar: sidebar ?? this.sidebar, + surface: surface ?? this.surface, + surfaceSubtle: surfaceSubtle ?? this.surfaceSubtle, + surfaceSubtleHover: surfaceSubtleHover ?? this.surfaceSubtleHover, + border: border ?? this.border, + borderMuted: borderMuted ?? this.borderMuted, + borderSubtle: borderSubtle ?? this.borderSubtle, + text: text ?? this.text, + strong: strong ?? this.strong, + muted: muted ?? this.muted, + accent: accent ?? this.accent, + accentHover: accentHover ?? this.accentHover, + accentText: accentText ?? this.accentText, + accentBg: accentBg ?? this.accentBg, + accentBgStrong: accentBgStrong ?? this.accentBgStrong, + onAccent: onAccent ?? this.onAccent, + error: error ?? this.error, + success: success ?? this.success, + warning: warning ?? this.warning, + info: info ?? this.info, + hoverBg: hoverBg ?? this.hoverBg, + inputBg: inputBg ?? this.inputBg, + focusRing: focusRing ?? this.focusRing, + codeBg: codeBg ?? this.codeBg, + codeText: codeText ?? this.codeText, + ); + } + + @override + HermesTokens lerp(covariant ThemeExtension? other, double t) { + if (other is! HermesTokens) { + return this; + } + Color c(Color a, Color b) => Color.lerp(a, b, t)!; + return HermesTokens( + bg: c(bg, other.bg), + sidebar: c(sidebar, other.sidebar), + surface: c(surface, other.surface), + surfaceSubtle: c(surfaceSubtle, other.surfaceSubtle), + surfaceSubtleHover: c(surfaceSubtleHover, other.surfaceSubtleHover), + border: c(border, other.border), + borderMuted: c(borderMuted, other.borderMuted), + borderSubtle: c(borderSubtle, other.borderSubtle), + text: c(text, other.text), + strong: c(strong, other.strong), + muted: c(muted, other.muted), + accent: c(accent, other.accent), + accentHover: c(accentHover, other.accentHover), + accentText: c(accentText, other.accentText), + accentBg: c(accentBg, other.accentBg), + accentBgStrong: c(accentBgStrong, other.accentBgStrong), + onAccent: c(onAccent, other.onAccent), + error: c(error, other.error), + success: c(success, other.success), + warning: c(warning, other.warning), + info: c(info, other.info), + hoverBg: c(hoverBg, other.hoverBg), + inputBg: c(inputBg, other.inputBg), + focusRing: c(focusRing, other.focusRing), + codeBg: c(codeBg, other.codeBg), + codeText: c(codeText, other.codeText), + ); + } +} diff --git a/lib/layouts/mint_y.dart b/lib/layouts/mint_y.dart index 44799ab..a7c8c66 100644 --- a/lib/layouts/mint_y.dart +++ b/lib/layouts/mint_y.dart @@ -1,11 +1,16 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:linux_assistant/l10n/app_localizations.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; class MintY { - static Color currentColor = const Color(0xff6db443); + /// Accent used for buttons, icons and the header gradient. + /// + /// Defaults to the Hermes gold. `MyApp.setMainColor()` may replace it with a + /// distribution-specific color when the user opts into that. + static Color currentColor = HermesTokens.light.accent; - static Color secondaryColor = const Color(0xff2ab9a4); + static Color secondaryColor = HermesTokens.light.accentHover; static bool dark = false; static MaterialColor currentColorTheme = green; @@ -56,14 +61,16 @@ class MintY { }, ); - static BoxDecoration colorfulBackground = BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - stops: const [0, 1], - colors: [currentColor, secondaryColor], - ), - ); + /// A getter, not a field: as a field it captured [currentColor] once at class + /// load time and kept showing the old gradient after a color or theme change. + static BoxDecoration get colorfulBackground => BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + stops: const [0, 1], + colors: [currentColor, secondaryColor], + ), + ); static const Color _white = Color.fromARGB(255, 255, 255, 255); @@ -73,8 +80,11 @@ class MintY { fontWeight: FontWeight.w500, decoration: TextDecoration.none); + // The non-White styles deliberately carry no color: they inherit it from the + // ambient DefaultTextStyle, which the active theme drives. That is what makes + // them legible in dark mode. The *White variants keep an explicit color + // because they sit on the accent gradient, where white is always correct. static const heading1 = TextStyle( - color: Colors.black, fontSize: 32, fontWeight: FontWeight.w500, decoration: TextDecoration.none); @@ -86,7 +96,6 @@ class MintY { decoration: TextDecoration.none); static const heading2 = TextStyle( - color: Colors.black, fontSize: 24, fontWeight: FontWeight.w400, decoration: TextDecoration.none); @@ -98,7 +107,6 @@ class MintY { decoration: TextDecoration.none); static const heading3 = TextStyle( - color: Colors.black, fontSize: 20, fontWeight: FontWeight.w400, decoration: TextDecoration.none); @@ -110,14 +118,12 @@ class MintY { decoration: TextDecoration.none); static const heading4 = TextStyle( - color: Colors.black, fontSize: 17, fontWeight: FontWeight.w400, decoration: TextDecoration.none); static const paragraph = TextStyle( fontWeight: FontWeight.w400, - color: Colors.black, decoration: TextDecoration.none, fontSize: 15); @@ -128,76 +134,148 @@ class MintY { fontSize: 15, ); - static ThemeData theme() => ThemeData( + /// [accent] overrides the Hermes gold, e.g. with a distribution color. The + /// derived tints and text colors are recomputed so contrast stays valid. + static ThemeData theme({Color? accent}) => _buildTheme( + accent == null + ? HermesTokens.light + : HermesTokens.light.withAccent(accent), + Brightness.light, + ); + + static ThemeData themeDark({Color? accent}) => _buildTheme( + accent == null + ? HermesTokens.dark + : HermesTokens.dark.withAccent(accent), + Brightness.dark, + ); + + /// Builds a theme from a [HermesTokens] palette. + /// + /// Both brightnesses go through here so that light and dark can never drift + /// apart. The previous themes set only `primaryColor` and left `colorScheme` + /// at its default, which meant every Material 3 component ignored the app's + /// colors entirely. + static ThemeData _buildTheme(HermesTokens t, Brightness brightness) { + final isDark = brightness == Brightness.dark; + + final scheme = ColorScheme( + brightness: brightness, + primary: t.accent, + onPrimary: t.onAccent, + primaryContainer: t.accentBgStrong, + onPrimaryContainer: t.accentText, + secondary: t.info, + onSecondary: isDark ? t.bg : const Color(0xFFFFFFFF), + secondaryContainer: t.surfaceSubtle, + onSecondaryContainer: t.text, + tertiary: t.accentText, + onTertiary: isDark ? t.bg : const Color(0xFFFFFFFF), + error: t.error, + onError: isDark ? t.bg : const Color(0xFFFFFFFF), + errorContainer: t.surfaceSubtle, + onErrorContainer: t.error, + surface: t.bg, + onSurface: t.text, + onSurfaceVariant: t.muted, + surfaceContainerLowest: t.bg, + surfaceContainerLow: t.sidebar, + surfaceContainer: t.surfaceSubtle, + surfaceContainerHigh: t.surface, + surfaceContainerHighest: t.surfaceSubtleHover, + outline: t.border, + outlineVariant: t.borderSubtle, + inverseSurface: t.text, + onInverseSurface: t.bg, + inversePrimary: t.accentHover, + shadow: const Color(0xFF000000), + scrim: const Color(0xFF000000), + ); + + // The heading/paragraph styles carry no color of their own, so apply the + // token colors once here instead of duplicating a *White variant per slot. + const base = TextTheme( + displayLarge: heading1, + displayMedium: heading1, + displaySmall: heading2, + headlineLarge: heading2, + headlineMedium: heading3, + headlineSmall: heading4, + titleLarge: heading3, + titleMedium: heading4, + titleSmall: paragraph, + bodyLarge: paragraph, + bodyMedium: paragraph, + bodySmall: paragraph, + labelLarge: paragraph, + labelMedium: paragraph, + labelSmall: paragraph, + ); + + return ThemeData( useMaterial3: true, - primaryColor: currentColor, - brightness: Brightness.light, - // backgroundColor: Colors.white70, - textTheme: const TextTheme( - displayLarge: heading1, - displayMedium: heading1, - displaySmall: heading2, - headlineLarge: heading2, - headlineMedium: heading3, - headlineSmall: heading4, - titleLarge: heading3, - titleMedium: heading4, - titleSmall: paragraph, - bodyLarge: paragraph, - bodyMedium: paragraph, - bodySmall: paragraph, - labelLarge: paragraph, - labelMedium: paragraph, - labelSmall: paragraph, + brightness: brightness, + colorScheme: scheme, + primaryColor: t.accent, + scaffoldBackgroundColor: t.bg, + canvasColor: t.bg, + cardColor: t.surface, + dividerColor: t.border, + hoverColor: t.hoverBg, + extensions: >[t], + textTheme: base.apply( + bodyColor: t.text, + displayColor: t.strong, ), - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderSide: BorderSide(color: MintY.currentColor), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: MintY.currentColor, width: 2, style: BorderStyle.solid), + dividerTheme: DividerThemeData( + color: t.border, + space: 1, + thickness: HermesTokens.borderWidth, + ), + cardTheme: CardThemeData( + color: t.surface, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(HermesTokens.radiusMd), + side: BorderSide(color: t.border, width: HermesTokens.borderWidth), ), ), - textSelectionTheme: - TextSelectionThemeData(selectionColor: MintY.currentColor)); - - static ThemeData themeDark() => ThemeData( - useMaterial3: true, - primaryColor: currentColor, - canvasColor: const Color.fromARGB(255, 31, 31, 31), - brightness: Brightness.dark, - // backgroundColor: const Color.fromARGB(255, 31, 31, 31), - cardColor: const Color.fromARGB(255, 45, 45, 45), - highlightColor: _white, - textTheme: const TextTheme( - displayLarge: heading1White, - displayMedium: heading1White, - displaySmall: heading2White, - headlineLarge: heading2White, - headlineMedium: heading3White, - headlineSmall: heading4White, - titleLarge: heading3White, - titleMedium: heading4White, - titleSmall: paragraphWhite, - bodyLarge: paragraphWhite, - bodyMedium: paragraphWhite, - bodySmall: paragraphWhite, - labelLarge: paragraphWhite, - labelMedium: paragraphWhite, - labelSmall: paragraphWhite, + iconTheme: IconThemeData(color: t.muted), + tooltipTheme: TooltipThemeData( + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border, width: HermesTokens.borderWidth), + borderRadius: BorderRadius.circular(HermesTokens.radiusSm), + ), + textStyle: TextStyle(color: t.text, fontSize: 12), ), inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: t.surfaceSubtle, + hintStyle: TextStyle(color: t.muted), border: OutlineInputBorder( - borderSide: BorderSide(color: MintY.currentColor), + borderRadius: BorderRadius.circular(HermesTokens.radiusMd), + borderSide: + BorderSide(color: t.border, width: HermesTokens.borderWidth), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(HermesTokens.radiusMd), + borderSide: + BorderSide(color: t.border, width: HermesTokens.borderWidth), ), focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: MintY.currentColor, width: 2, style: BorderStyle.solid), + borderRadius: BorderRadius.circular(HermesTokens.radiusMd), + borderSide: BorderSide(color: t.accent, width: 2), ), ), - textSelectionTheme: - TextSelectionThemeData(selectionColor: MintY.currentColor)); + textSelectionTheme: TextSelectionThemeData( + cursorColor: t.accent, + selectionColor: t.accentBgStrong, + selectionHandleColor: t.accent, + ), + ); + } static void showMessage( BuildContext context, String message, VoidCallback? callback) { diff --git a/lib/main.dart b/lib/main.dart index 783416b..d532369 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,9 +6,12 @@ import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:linux_assistant/enums/distros.dart'; import 'package:linux_assistant/layouts/greeter/flathub_permissions.dart'; import 'package:linux_assistant/layouts/greeter/start_screen.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; +import 'package:linux_assistant/layouts/hub/hub_shell.dart'; import 'package:linux_assistant/layouts/mint_y.dart'; import 'package:linux_assistant/services/config_handler.dart'; import 'package:linux_assistant/services/linux.dart'; +import 'package:linux_assistant/services/theme_controller.dart'; import 'package:window_manager/window_manager.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:linux_assistant/l10n/app_localizations.dart'; @@ -24,7 +27,7 @@ void main() async { await HotKeyManager.instance.unregisterAll(); StatelessWidget firstScreen = const StartScreen(); - bool darkTheme = false; + bool systemIsDark = false; // Are we in a flatpak? Is the folder /app/bin/ present? // Do we have flatpak-spawn? var runningInFlatpak = Directory("/app/bin/").existsSync(); @@ -42,9 +45,8 @@ void main() async { if (firstScreen is StartScreen) { await Linux.init(); - darkTheme = await Linux.isDarkThemeEnabled(); - darkTheme = - await ConfigHandler().getValue("dark_theme_activated", darkTheme); + systemIsDark = await Linux.isDarkThemeEnabled(); + await ThemeController().load(systemIsDark: systemIsDark); String versionFile = "${Linux.executableFolder}/version"; if (await File(versionFile).exists()) { @@ -58,37 +60,25 @@ void main() async { } runApp(MyApp( - darkTheme: darkTheme, + systemIsDark: systemIsDark, firstPage: firstScreen, )); } -class MyApp extends StatelessWidget { - late bool darkTheme; - late StatelessWidget firstPage; - MyApp({Key? key, this.darkTheme = false, required this.firstPage}) - : super(key: key); +class MyApp extends StatefulWidget { + /// What the desktop environment reports, used when the user follows the + /// system theme. + final bool systemIsDark; + final Widget firstPage; + + const MyApp({ + super.key, + this.systemIsDark = false, + required this.firstPage, + }); + @override - Widget build(BuildContext context) { - setMainColor(); - initHotkeyToShowUp(); - return MaterialApp( - title: 'Linux Assistant', - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: const [ - Locale('en', ''), - Locale('de', ''), - Locale('it', ''), - ], - theme: darkTheme ? MintY.themeDark() : MintY.theme(), - home: firstPage, - ); - } + State createState() => _MyAppState(); static void initHotkeyToShowUp() { HotKey hotKey = HotKey( @@ -101,11 +91,28 @@ class MyApp extends StatelessWidget { keyDownHandler: (hotKey) { Linux.runCommandWithCustomArguments( "wmctrl", ["-a", "Linux Assistant"]); + // Raising the window is only half the job when the hub is open: the + // hotkey is meant to land the user in the search box. + HubShell.onSearchRequested?.call(); }, ); } - static void setMainColor() { + /// Applies the accent colors for the current appearance. + /// + /// The app now ships the Hermes palette by default; the per-distribution + /// colors below are opt-in via the `use_distro_colors` setting. An explicit + /// `main_color` / `secondary_color` in the config still overrides both. + static void setMainColor({required bool isDark}) { + final tokens = isDark ? HermesTokens.dark : HermesTokens.light; + + if (!ThemeController().useDistroColors) { + MintY.currentColor = tokens.accent; + MintY.secondaryColor = tokens.accentHover; + _applyConfiguredColorOverrides(); + return; + } + MintY.secondaryColor = const Color(0xff2ab9a4); switch (Linux.currentenvironment.distribution) { case DISTROS.DEBIAN: @@ -160,6 +167,10 @@ class MyApp extends StatelessWidget { default: MintY.currentColor = Colors.blue; } + _applyConfiguredColorOverrides(); + } + + static void _applyConfiguredColorOverrides() { ConfigHandler configHandler = ConfigHandler(); String colorString = configHandler.getValueUnsafe("main_color", ""); if (colorString.isNotEmpty) { @@ -171,3 +182,57 @@ class MyApp extends StatelessWidget { } } } + +class _MyAppState extends State { + final ThemeController _themeController = ThemeController(); + + @override + void initState() { + super.initState(); + MyApp.initHotkeyToShowUp(); + _themeController.addListener(_onAppearanceChanged); + } + + @override + void dispose() { + _themeController.removeListener(_onAppearanceChanged); + super.dispose(); + } + + void _onAppearanceChanged() => setState(() {}); + + @override + Widget build(BuildContext context) { + final bool isDark = _themeController.isDark(widget.systemIsDark); + + // Widgets that predate the theme extension read this static directly. + MintY.dark = isDark; + MyApp.setMainColor(isDark: isDark); + + final Color? accent = + _themeController.useDistroColors ? MintY.currentColor : null; + + return MaterialApp( + title: 'Linux Assistant', + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [ + Locale('en', ''), + Locale('de', ''), + Locale('it', ''), + ], + theme: MintY.theme(accent: accent), + darkTheme: MintY.themeDark(accent: accent), + // Resolved here rather than handed to Flutter as ThemeMode.system: the + // desktop's setting comes from gsettings/kdeglobals via + // Linux.isDarkThemeEnabled(), which the platform brightness does not + // reliably mirror on Linux. + themeMode: isDark ? ThemeMode.dark : ThemeMode.light, + home: widget.firstPage, + ); + } +} diff --git a/lib/services/theme_controller.dart b/lib/services/theme_controller.dart new file mode 100644 index 0000000..79de02a --- /dev/null +++ b/lib/services/theme_controller.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:linux_assistant/services/config_handler.dart'; + +/// Holds the app-wide appearance state and notifies on change. +/// +/// Before this existed, the theme was read once in `main()` and baked into a +/// `StatelessWidget`, so switching between light and dark required restarting +/// the app. Listening to this controller lets `MaterialApp` rebuild in place. +/// +/// A plain [ChangeNotifier] is deliberate: the app has no state-management +/// package, and pulling one in for two booleans would be a heavier change than +/// the problem warrants. +class ThemeController extends ChangeNotifier { + static final ThemeController _instance = ThemeController._(); + + factory ThemeController() => _instance; + + ThemeController._(); + + static const String _themeModeKey = "theme_mode"; + static const String _legacyDarkKey = "dark_theme_activated"; + static const String _useDistroColorsKey = "use_distro_colors"; + + ThemeMode _themeMode = ThemeMode.system; + bool _useDistroColors = false; + + ThemeMode get themeMode => _themeMode; + bool get useDistroColors => _useDistroColors; + + /// True when the app should currently render dark. + /// + /// [systemIsDark] is what the desktop environment reports, used only when the + /// mode is [ThemeMode.system]. + bool isDark(bool systemIsDark) { + switch (_themeMode) { + case ThemeMode.light: + return false; + case ThemeMode.dark: + return true; + case ThemeMode.system: + return systemIsDark; + } + } + + /// Loads the persisted preferences. + /// + /// [systemIsDark] is the desktop's current setting, used to migrate the old + /// `dark_theme_activated` boolean: if the user had explicitly flipped it away + /// from what the system reports, that choice is preserved as an explicit + /// light/dark mode instead of silently becoming "follow system". + Future load({required bool systemIsDark}) async { + final config = ConfigHandler(); + + final storedMode = await config.getValue(_themeModeKey, null); + if (storedMode is String) { + _themeMode = _parseThemeMode(storedMode); + } else { + final legacyDark = await config.getValue(_legacyDarkKey, systemIsDark); + _themeMode = (legacyDark == systemIsDark) + ? ThemeMode.system + : (legacyDark == true ? ThemeMode.dark : ThemeMode.light); + } + + _useDistroColors = + await config.getValue(_useDistroColorsKey, false) == true; + + notifyListeners(); + } + + Future setThemeMode(ThemeMode mode) async { + if (_themeMode == mode) { + return; + } + _themeMode = mode; + notifyListeners(); + await ConfigHandler().setValue(_themeModeKey, _serializeThemeMode(mode)); + } + + Future setUseDistroColors(bool value) async { + if (_useDistroColors == value) { + return; + } + _useDistroColors = value; + notifyListeners(); + await ConfigHandler().setValue(_useDistroColorsKey, value); + } + + /// Cycles system -> light -> dark -> system, for the toolbar toggle. + Future cycleThemeMode() async { + switch (_themeMode) { + case ThemeMode.system: + await setThemeMode(ThemeMode.light); + break; + case ThemeMode.light: + await setThemeMode(ThemeMode.dark); + break; + case ThemeMode.dark: + await setThemeMode(ThemeMode.system); + break; + } + } + + static ThemeMode _parseThemeMode(String value) { + switch (value) { + case "light": + return ThemeMode.light; + case "dark": + return ThemeMode.dark; + default: + return ThemeMode.system; + } + } + + static String _serializeThemeMode(ThemeMode mode) { + switch (mode) { + case ThemeMode.light: + return "light"; + case ThemeMode.dark: + return "dark"; + case ThemeMode.system: + return "system"; + } + } + + /// Test seam: resets to defaults so widget tests start from a known state. + @visibleForTesting + void resetForTesting() { + _themeMode = ThemeMode.system; + _useDistroColors = false; + } +} From 735b816aa3d93e6d3d2acb7851b5e049212b8262 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:59:36 +0000 Subject: [PATCH 2/6] Poll system stats once for all tiles instead of per widget Every live tile owned a Timer.periodic that called setState with an empty body. Because each tile also built its Future inside build(), that re-ran the underlying command on every tick: SystemStatus forked `free` and `/proc/loadavg` every 2s, and the Linux Health page forked seven commands every 5s. Health also leaked its timer unless the user happened to leave through the back button. SystemStatsService collects one snapshot per interval and publishes it through a ValueNotifier. Polling is reference counted, so it stops when no tile is on screen, and an in-flight poll is not allowed to overlap -- on a busy machine `ps` can outlast the interval, and stacking runs would only make it worse. A failed poll keeps the last good snapshot and records the error rather than freezing silently. getCpuThreadCount() now caches: the count cannot change while the app runs, and it was forking `nproc` on every CPU reading. The df, ps and uptime parsers are split into pure functions so they can be tested against captured output instead of the live system. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HioAo2j6ufA9GsyKBWF69u --- lib/layouts/linux_health/overview.dart | 360 ++++++++++++------------- lib/linux/linux_filesystem.dart | 6 + lib/linux/linux_process.dart | 12 + lib/linux/linux_system.dart | 26 +- lib/services/system_stats_service.dart | 265 ++++++++++++++++++ lib/widgets/memory_status.dart | 162 +++++------ 6 files changed, 549 insertions(+), 282 deletions(-) create mode 100644 lib/services/system_stats_service.dart diff --git a/lib/layouts/linux_health/overview.dart b/lib/layouts/linux_health/overview.dart index 10137ab..96569f8 100644 --- a/lib/layouts/linux_health/overview.dart +++ b/lib/layouts/linux_health/overview.dart @@ -1,206 +1,202 @@ -import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:linux_assistant/l10n/app_localizations.dart'; import 'package:linux_assistant/layouts/mint_y.dart'; import 'package:linux_assistant/linux/linux_filesystem.dart'; -import 'package:linux_assistant/linux/linux_process.dart'; -import 'package:linux_assistant/linux/linux_system.dart'; -import 'package:linux_assistant/l10n/app_localizations.dart'; import 'package:linux_assistant/services/main_search_loader.dart'; +import 'package:linux_assistant/services/system_stats_service.dart'; import 'package:linux_assistant/widgets/success_message.dart'; import 'package:linux_assistant/widgets/warning_message.dart'; -class LinuxHealthOverview extends StatefulWidget { +/// Standalone Linux Health page, reached from search and from the hub. +class LinuxHealthOverview extends StatelessWidget { const LinuxHealthOverview({super.key}); @override - State createState() => _LinuxHealthOverviewStat(); + Widget build(BuildContext context) { + return MintYPage( + title: AppLocalizations.of(context)!.linuxHealth, + customContentElement: const Expanded( + child: Padding( + padding: EdgeInsets.all(16.0), + child: LinuxHealthContent(), + ), + ), + bottom: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MintYButtonNavigate( + route: const MainSearchLoader(), + color: MintY.currentColor, + text: Text( + AppLocalizations.of(context)!.backToSearch, + style: MintY.heading4White, + ), + ) + ], + ), + ); + } +} + +/// The health findings themselves, without any page chrome, so the hub can +/// embed them as a section. +/// +/// This used to build seven futures inside `build()` and drive them with a +/// `Timer.periodic` that called an empty `setState`, which re-ran all seven +/// commands every five seconds and leaked the timer unless the user happened to +/// leave through the back button. It now reads the shared snapshot instead. +class LinuxHealthContent extends StatefulWidget { + const LinuxHealthContent({super.key}); + + @override + State createState() => _LinuxHealthContentState(); } -class _LinuxHealthOverviewStat extends State { - Timer? reloadTimer; +class _LinuxHealthContentState extends State { + final SystemStatsService _service = SystemStatsService(); + + @override + void initState() { + super.initState(); + _service.acquire(); + } + + @override + void dispose() { + _service.release(); + super.dispose(); + } @override Widget build(BuildContext context) { - int maxProcs = 3; - var futures = [ - LinuxFilesystem.disks(), - LinuxProcess.processCount(), - LinuxProcess.topProcessesByCpu(maxProcs), - LinuxProcess.topProcessesByMemory(maxProcs), - LinuxProcess.zombieCount(), - LinuxSystem.hasSwap(), - LinuxSystem.uptime() - ]; - - reloadTimer ??= - Timer.periodic(const Duration(seconds: 5), (timer) => setState(() {})); - - return FutureBuilder( - future: Future.wait(futures), - builder: (context, AsyncSnapshot> snapshot) { - if (snapshot.hasData) { - List results = snapshot.data!; - var devices = results[0] as List; - var procCount = results[1] as int; - var topProcsCpu = results[2] as List; - var topProcsMem = results[3] as List; - var zombies = results[4] as int; - var hasSwap = results[5] as bool; - var uptime = results[6] as Uptime; - - var removableCount = devices.where((x) => x.isRemovable).length; - List diskWarnings = []; - for (var device in devices) { - if (device.usedPercent > 90) { - var warningMessage = WarningMessage( - text: "${AppLocalizations.of(context)!.diskspaceWarning1}" - "${device.filesystem} ${device.mountPoint}" - "${AppLocalizations.of(context)!.diskspaceWarning2}" - "${device.sizeFree}"); - diskWarnings.add(warningMessage); - } - } - - List> topCPUProcesses = [ - [ - AppLocalizations.of(context)!.cpuUsage, - AppLocalizations.of(context)!.process - ] - ]; - - for (var stat in topProcsCpu) { - topCPUProcesses.add(["${stat.metricValue}%", stat.processName]); - } - - List> topMemoryProcesses = [ - [ - AppLocalizations.of(context)!.memoryUsage, - AppLocalizations.of(context)!.process - ] - ]; - - for (var stat in topProcsMem) { - topMemoryProcesses.add(["${stat.metricValue}%", stat.processName]); - } - - String uptimeUnitText; - switch (uptime.unit) { - case "m": - uptimeUnitText = AppLocalizations.of(context)!.minutes; - break; - case "h": - uptimeUnitText = AppLocalizations.of(context)!.hours; - break; - default: - uptimeUnitText = AppLocalizations.of(context)!.days; - break; - } - - bool uptimeWarning = - uptime.unit == "d" || (uptime.unit == "h" && uptime.value >= 10); - - return MintYPage( - title: AppLocalizations.of(context)!.linuxHealth, - contentElements: [ - Text( - AppLocalizations.of(context)!.runtime, - style: Theme.of(context).textTheme.headlineLarge, + final l10n = AppLocalizations.of(context)!; + + return ValueListenableBuilder( + valueListenable: _service.stats, + builder: (context, stats, _) { + if (!stats.hasData) { + return MintYLoadingPage(text: l10n.analysingLinuxHealth); + } + + final devices = stats.disks; + final removableCount = devices.where((x) => x.isRemovable).length; + + final List diskWarnings = [ + for (final DeviceInfo device in devices) + if (device.usedPercent > 90) + WarningMessage( + text: "${l10n.diskspaceWarning1}" + "${device.filesystem} ${device.mountPoint}" + "${l10n.diskspaceWarning2}" + "${device.sizeFree}", ), - uptimeWarning - ? WarningMessage( - text: AppLocalizations.of(context)! - .uptimeWarning(uptime.value, uptimeUnitText)) - : SuccessMessage( - text: AppLocalizations.of(context)! - .uptimePass(uptime.value, uptimeUnitText)), - zombies == 0 - ? SuccessMessage( - text: AppLocalizations.of(context)! - .processesWithZombiesMessage(procCount, zombies)) - : WarningMessage( - text: AppLocalizations.of(context)! - .processesWithZombiesMessage(procCount, zombies)), - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: Column( - children: [ - Text( - AppLocalizations.of(context)!.topMemoryProcesses, - style: Theme.of(context).textTheme.headlineMedium, - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: MintYTable(data: topMemoryProcesses), - ), - ], - ), - ), - Expanded( - child: Column( - children: [ - Text( - AppLocalizations.of(context)!.topCPUProcesses, - style: Theme.of(context).textTheme.headlineMedium, - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: MintYTable(data: topCPUProcesses), - ), - ], - ), + ]; + + final topCPUProcesses = >[ + [l10n.cpuUsage, l10n.process], + for (final stat in stats.topByCpu.take(3)) + ["${stat.metricValue}%", stat.processName], + ]; + + final topMemoryProcesses = >[ + [l10n.memoryUsage, l10n.process], + for (final stat in stats.topByMemory.take(3)) + ["${stat.metricValue}%", stat.processName], + ]; + + final uptime = stats.uptime; + final hasSwap = stats.memory?.hasSwap ?? false; + + return ListView( + shrinkWrap: true, + children: [ + Text( + l10n.runtime, + style: Theme.of(context).textTheme.headlineLarge, + ), + if (uptime != null) + _uptimeMessage(context, uptime.unit, uptime.value), + stats.zombieCount == 0 + ? SuccessMessage( + text: l10n.processesWithZombiesMessage( + stats.processCount, stats.zombieCount)) + : WarningMessage( + text: l10n.processesWithZombiesMessage( + stats.processCount, stats.zombieCount)), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Column( + children: [ + Text( + l10n.topMemoryProcesses, + style: Theme.of(context).textTheme.headlineMedium, + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: MintYTable(data: topMemoryProcesses), + ), + ], ), - ], - ), - ), - Text( - AppLocalizations.of(context)!.memoryAndStorage, - style: Theme.of(context).textTheme.headlineLarge, - ), - diskWarnings.isEmpty - ? SuccessMessage( - text: AppLocalizations.of(context)!.diskspacePass, - ) - : Column( - children: diskWarnings, + ), + Expanded( + child: Column( + children: [ + Text( + l10n.topCPUProcesses, + style: Theme.of(context).textTheme.headlineMedium, + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: MintYTable(data: topCPUProcesses), + ), + ], ), - removableCount == 0 - ? SuccessMessage( - text: AppLocalizations.of(context)!.removableDevicesPass) - : WarningMessage( - text: AppLocalizations.of(context)! - .removableDevicesWarning(removableCount)), - hasSwap - ? SuccessMessage( - text: AppLocalizations.of(context)!.swapsPass) - : WarningMessage( - text: AppLocalizations.of(context)!.swapsWarning), - ], - bottom: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - MintYButtonNavigate( - route: const MainSearchLoader(), - color: MintY.currentColor, - text: Text( - AppLocalizations.of(context)!.backToSearch, - style: MintY.heading4White, ), - onPressed: () => reloadTimer?.cancel(), - ) - ], + ], + ), ), - ); - } else if (snapshot.hasError) { - return Text(snapshot.error.toString()); - } else { - return MintYLoadingPage( - text: AppLocalizations.of(context)!.analysingLinuxHealth); - } + Text( + l10n.memoryAndStorage, + style: Theme.of(context).textTheme.headlineLarge, + ), + diskWarnings.isEmpty + ? SuccessMessage(text: l10n.diskspacePass) + : Column(children: diskWarnings), + removableCount == 0 + ? SuccessMessage(text: l10n.removableDevicesPass) + : WarningMessage( + text: l10n.removableDevicesWarning(removableCount)), + hasSwap + ? SuccessMessage(text: l10n.swapsPass) + : WarningMessage(text: l10n.swapsWarning), + ], + ); }, ); } + + Widget _uptimeMessage(BuildContext context, String unit, int value) { + final l10n = AppLocalizations.of(context)!; + final String unitText; + switch (unit) { + case "m": + unitText = l10n.minutes; + break; + case "h": + unitText = l10n.hours; + break; + default: + unitText = l10n.days; + break; + } + + final bool warn = unit == "d" || (unit == "h" && value >= 10); + return warn + ? WarningMessage(text: l10n.uptimeWarning(value, unitText)) + : SuccessMessage(text: l10n.uptimePass(value, unitText)); + } } diff --git a/lib/linux/linux_filesystem.dart b/lib/linux/linux_filesystem.dart index a3c6d5a..1d61832 100644 --- a/lib/linux/linux_filesystem.dart +++ b/lib/linux/linux_filesystem.dart @@ -33,6 +33,12 @@ abstract class LinuxFilesystem { "/usr/bin/df", ["-h"], getErrorMessages: false); + return parseDfOutput(cmdResult); + } + + /// Pure parser for `df -h` output, split out so it can be tested without + /// shelling out. + static List parseDfOutput(String cmdResult) { Iterable lines = cmdResult .split("\n") .skip(1) diff --git a/lib/linux/linux_process.dart b/lib/linux/linux_process.dart index 1e283d1..0b39cf3 100644 --- a/lib/linux/linux_process.dart +++ b/lib/linux/linux_process.dart @@ -13,10 +13,22 @@ abstract class LinuxProcess { var cmdResult = await Linux.runCommandWithCustomArguments( "/usr/bin/ps", ["-eo", "$metric,args", "--sort=-$metric"]); + return parsePsOutput(cmdResult, count); + } + + /// Pure parser for `ps -eo ,args` output, split out so it can be + /// tested without shelling out. + /// + /// Lines that carry a metric but no command are skipped rather than throwing; + /// `ps` emits those for kernel threads on some systems. + static List parsePsOutput(String cmdResult, int count) { var processes = List.empty(growable: true); for (var line in cmdResult.split("\n").skip(1).take(count)) { var values = line.split(" "); values.removeWhere((x) => x == ""); + if (values.length < 2) { + continue; + } processes.add(ProcessStat(values[0], values[1].split("/").last)); } diff --git a/lib/linux/linux_system.dart b/lib/linux/linux_system.dart index 89e7f19..5c0d78b 100644 --- a/lib/linux/linux_system.dart +++ b/lib/linux/linux_system.dart @@ -28,7 +28,13 @@ abstract class LinuxSystem { throw Exception(cmdResult.output); } - var values = cmdResult.output.replaceAll(RegExp(r" +"), " ").split(" "); + return parseUptime(cmdResult.output); + } + + /// Pure parser for `uptime` output, split out so it can be tested without + /// shelling out. + static Uptime parseUptime(String output) { + var values = output.replaceAll(RegExp(r" +"), " ").trim().split(" "); if (values[2].contains(":")) { var arr = values[2].split(":"); int hourValue = int.parse(arr[0]); @@ -36,25 +42,35 @@ abstract class LinuxSystem { return hourValue == 0 ? Uptime("m", minuteValue) : Uptime("h", hourValue); } else { // The new uptime output could be: 1 day, 1:23 - if (cmdResult.output.contains("min")) { + if (output.contains("min")) { return Uptime("m", int.parse(values[2])); } - if (cmdResult.output.contains("day")) { + if (output.contains("day")) { return Uptime("d", int.parse(values[2])); } - if (cmdResult.output.contains("hour")) { + if (output.contains("hour")) { return Uptime("h", int.parse(values[2])); } return Uptime("m", int.parse(values[2])); } } + /// Cached: the CPU thread count cannot change while the app is running, and + /// the polling dashboard would otherwise fork `nproc` on every tick. + static int? _cachedThreadCount; + static Future getCpuThreadCount() async { + final cached = _cachedThreadCount; + if (cached != null) { + return cached; + } var cmdResult = await CommandHelper.run("/usr/bin/nproc"); if (!cmdResult.success) { print("Error: ${cmdResult.error}"); } - return int.parse(cmdResult.output); + final count = int.parse(cmdResult.output); + _cachedThreadCount = count; + return count; } /// Returns the average load of the CPU of the last minute diff --git a/lib/services/system_stats_service.dart b/lib/services/system_stats_service.dart new file mode 100644 index 0000000..9ef0c72 --- /dev/null +++ b/lib/services/system_stats_service.dart @@ -0,0 +1,265 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:linux_assistant/linux/linux_filesystem.dart'; +import 'package:linux_assistant/linux/linux_process.dart'; +import 'package:linux_assistant/linux/linux_system.dart'; +import 'package:linux_assistant/services/linux.dart'; + +/// Memory figures as reported by `free -m`, in mebibytes. +@immutable +class MemoryInfo { + final int totalMb; + final int usedMb; + final int swapTotalMb; + final int swapUsedMb; + + const MemoryInfo({ + required this.totalMb, + required this.usedMb, + required this.swapTotalMb, + required this.swapUsedMb, + }); + + double get usedRatio => totalMb > 0 ? usedMb / totalMb : 0; + + bool get hasSwap => swapTotalMb > 0; + + double get swapUsedRatio => swapTotalMb > 0 ? swapUsedMb / swapTotalMb : 0; + + /// Pure parser for `free -m`. Returns null when the output is unusable, so + /// callers can keep the previous reading instead of showing a broken tile. + static MemoryInfo? parseFreeOutput(String output) { + final lines = output.split("\n"); + if (lines.length < 2) { + return null; + } + + List columns(String line) => + line.split(" ").where((x) => x.isNotEmpty).toList(); + + try { + final mem = columns(lines[1]); + if (mem.length < 3) { + return null; + } + int swapTotal = 0; + int swapUsed = 0; + if (lines.length >= 3) { + final swap = columns(lines[2]); + if (swap.length >= 3) { + swapTotal = int.parse(swap[1]); + swapUsed = int.parse(swap[2]); + } + } + return MemoryInfo( + totalMb: int.parse(mem[1]), + usedMb: int.parse(mem[2]), + swapTotalMb: swapTotal, + swapUsedMb: swapUsed, + ); + } on FormatException { + return null; + } on RangeError { + return null; + } + } +} + +/// One snapshot of the machine's state. +@immutable +class SystemStats { + /// CPU load over the last minute, normalized by thread count. 0.0 to ~1.0, + /// but can exceed 1.0 on an overloaded machine. + final double cpuLoad; + final MemoryInfo? memory; + final List disks; + final int processCount; + final int zombieCount; + final List topByCpu; + final List topByMemory; + final Uptime? uptime; + + /// Rolling history for sparklines, oldest first. + final List cpuHistory; + final List memoryHistory; + + /// Null until the first successful poll. + final DateTime? updatedAt; + + /// Set when the last poll threw, so the UI can show stale data honestly + /// rather than silently freezing. + final String? error; + + const SystemStats({ + this.cpuLoad = 0, + this.memory, + this.disks = const [], + this.processCount = 0, + this.zombieCount = 0, + this.topByCpu = const [], + this.topByMemory = const [], + this.uptime, + this.cpuHistory = const [], + this.memoryHistory = const [], + this.updatedAt, + this.error, + }); + + bool get hasData => updatedAt != null; +} + +/// Polls the system once per interval and shares the result with every widget +/// that needs it. +/// +/// Each dashboard tile used to own a `Timer.periodic` that called `setState` +/// with an empty body, which re-ran the tile's `Future` and forked `df`, `ps` +/// or `free` again. With eight tiles on screen that is eight process spawns per +/// tick. This service polls once and notifies listeners. +/// +/// Polling is reference counted: [acquire] starts it, [release] stops it when +/// the last listener goes away, so a backgrounded hub costs nothing. +class SystemStatsService { + static final SystemStatsService _instance = SystemStatsService._(); + + factory SystemStatsService() => _instance; + + SystemStatsService._(); + + static const int historyLength = 60; + static const Duration defaultInterval = Duration(seconds: 3); + + final ValueNotifier stats = + ValueNotifier(const SystemStats()); + + Duration interval = defaultInterval; + + Timer? _timer; + int _subscribers = 0; + + /// Guards against overlapping polls: on a busy machine `ps` can take longer + /// than the interval, and stacking runs would make it worse. + bool _polling = false; + + bool get isRunning => _timer != null; + + @visibleForTesting + int get subscriberCount => _subscribers; + + /// Registers interest and starts polling if this is the first subscriber. + void acquire() { + _subscribers++; + if (_subscribers == 1) { + _start(); + } + } + + /// Drops interest and stops polling once nobody is left. + void release() { + if (_subscribers == 0) { + return; + } + _subscribers--; + if (_subscribers == 0) { + _stop(); + } + } + + void _start() { + _timer?.cancel(); + _timer = Timer.periodic(interval, (_) => refresh()); + // Don't make the first tile wait a full interval for content. + refresh(); + } + + void _stop() { + _timer?.cancel(); + _timer = null; + } + + /// Collects a fresh snapshot. Safe to call directly for a manual reload. + Future refresh() async { + if (_polling) { + return; + } + _polling = true; + final previous = stats.value; + try { + final results = await Future.wait([ + LinuxSystem.getCpuAverageLoad(), + Linux.runCommand("free -m", hostOnFlatpak: false), + LinuxFilesystem.disks(), + LinuxProcess.processCount(), + LinuxProcess.zombieCount(), + LinuxProcess.topProcessesByCpu(5), + LinuxProcess.topProcessesByMemory(5), + _uptimeOrNull(), + ]); + + final cpuLoad = results[0] as double; + final memory = MemoryInfo.parseFreeOutput(results[1] as String); + + stats.value = SystemStats( + cpuLoad: cpuLoad, + memory: memory ?? previous.memory, + disks: results[2] as List, + processCount: results[3] as int, + zombieCount: results[4] as int, + topByCpu: results[5] as List, + topByMemory: results[6] as List, + uptime: results[7] as Uptime? ?? previous.uptime, + cpuHistory: _appendToHistory(previous.cpuHistory, cpuLoad), + memoryHistory: _appendToHistory( + previous.memoryHistory, + (memory ?? previous.memory)?.usedRatio ?? 0, + ), + updatedAt: DateTime.now(), + ); + } catch (e) { + // Keep the last good snapshot visible and record why it stopped updating. + stats.value = SystemStats( + cpuLoad: previous.cpuLoad, + memory: previous.memory, + disks: previous.disks, + processCount: previous.processCount, + zombieCount: previous.zombieCount, + topByCpu: previous.topByCpu, + topByMemory: previous.topByMemory, + uptime: previous.uptime, + cpuHistory: previous.cpuHistory, + memoryHistory: previous.memoryHistory, + updatedAt: previous.updatedAt, + error: e.toString(), + ); + } finally { + _polling = false; + } + } + + /// `uptime` parsing is brittle across distributions, and a single tile is not + /// worth failing the whole snapshot for. + Future _uptimeOrNull() async { + try { + return await LinuxSystem.uptime(); + } catch (_) { + return null; + } + } + + static List _appendToHistory(List history, double value) { + final next = List.from(history)..add(value); + if (next.length > historyLength) { + next.removeRange(0, next.length - historyLength); + } + return next; + } + + /// Test seam: drops all state and stops any timer. + @visibleForTesting + void resetForTesting({SystemStats? seed}) { + _stop(); + _subscribers = 0; + _polling = false; + stats.value = seed ?? const SystemStats(); + } +} diff --git a/lib/widgets/memory_status.dart b/lib/widgets/memory_status.dart index 0ce9b1c..dba0ac8 100644 --- a/lib/widgets/memory_status.dart +++ b/lib/widgets/memory_status.dart @@ -1,135 +1,107 @@ -import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:linux_assistant/linux/linux_system.dart'; +import 'package:linux_assistant/services/system_stats_service.dart'; import 'package:linux_assistant/widgets/hardware_info.dart'; import 'package:linux_assistant/widgets/single_bar_chart.dart'; -import 'package:linux_assistant/services/linux.dart'; +/// CPU / RAM / Swap bars next to the machine's hardware summary. +/// +/// Reads from [SystemStatsService] instead of polling on its own, so having +/// this on screen alongside other tiles no longer multiplies the number of +/// `free` and `ps` calls. class SystemStatus extends StatefulWidget { - const SystemStatus({Key? key}) : super(key: key); + const SystemStatus({super.key}); @override State createState() => _SystemStatusState(); } -Future> _getAllFutures() async { - return await Future.wait([ - Linux.runCommand("free -m", hostOnFlatpak: false), - LinuxSystem.getCpuAverageLoad(), - ]); -} - class _SystemStatusState extends State { - // Start clock timer for refresh every 2 seconds - Timer? timer; + final SystemStatsService _service = SystemStatsService(); @override void initState() { super.initState(); - timer = Timer.periodic( - const Duration(seconds: 2), (Timer t) => setState(() {})); + _service.acquire(); + } + + @override + void dispose() { + _service.release(); + super.dispose(); } @override Widget build(BuildContext context) { - var allFutures = _getAllFutures(); - return FutureBuilder>( - future: allFutures, - builder: (context, snapshot) { - if (snapshot.hasData) { - String outputFreeM = snapshot.data![0] as String; - List lines = outputFreeM.split("\n"); - List widgets = []; + return ValueListenableBuilder( + valueListenable: _service.stats, + builder: (context, stats, _) { + if (!stats.hasData) { + return const Card( + child: Padding( + padding: EdgeInsets.all(10), + child: CircularProgressIndicator(), + ), + ); + } - // CPU - // double is in snapshot.data[1] (List) - double cpuLoad = snapshot.data![1] as double; - widgets.add(SingleBarChart( + final double cpuLoad = stats.cpuLoad; + final List widgets = [ + SingleBarChart( value: cpuLoad < 0.9 ? cpuLoad + 0.1 : min(cpuLoad, 1), - fillColor: - cpuLoad < 1 ? const Color.fromARGB(255, 70, 153, 221) : Colors.red, + fillColor: cpuLoad < 1 + ? const Color.fromARGB(255, 70, 153, 221) + : Colors.red, tooltip: "${(cpuLoad * 100).toStringAsFixed(2)}% (~1 min)", text: "CPU", - )); - - widgets.add(const SizedBox( - width: 15, - )); - - // RAM - if (lines.length >= 2) { - List values = convertLineToList(lines[1]); - widgets.add(SingleBarChart( - value: double.parse(values[2]) / double.parse(values[1]), - fillColor: const Color.fromARGB(255, 193, 119, 243), - tooltip: "${convertToGB(values[2])}/${convertToGB(values[1])}", - text: "RAM", - )); - } + ), + const SizedBox(width: 15), + ]; - widgets.add(const SizedBox( - width: 15, + final memory = stats.memory; + if (memory != null) { + widgets.add(SingleBarChart( + value: memory.usedRatio, + fillColor: const Color.fromARGB(255, 193, 119, 243), + tooltip: + "${_formatGb(memory.usedMb)}/${_formatGb(memory.totalMb)}", + text: "RAM", )); + widgets.add(const SizedBox(width: 15)); - // SWAP: - if (lines.length >= 3) { - List values = convertLineToList(lines[2]); + if (memory.hasSwap) { widgets.add(SingleBarChart( - value: double.parse(values[2]) / double.parse(values[1]), + value: memory.swapUsedRatio, fillColor: const Color.fromARGB(255, 223, 157, 58), - tooltip: "${convertToGB(values[2])}/${convertToGB(values[1])}", + tooltip: + "${_formatGb(memory.swapUsedMb)}/${_formatGb(memory.swapTotalMb)}", text: "Swap", )); - - widgets.add(const SizedBox( - width: 15, - )); + widgets.add(const SizedBox(width: 15)); } + } - // Remove last space widget if the swap is not available - widgets.removeLast(); - - widgets.add(const SizedBox( - width: 20, - )); - - widgets.add(const Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - HardwareInfo(), - ], - )); - - return Card( - child: Container( - padding: const EdgeInsets.all(10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: widgets, - ), + widgets.add(const SizedBox(width: 20)); + widgets.add(const Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [HardwareInfo()], + )); + + return Card( + child: Container( + padding: const EdgeInsets.all(10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: widgets, ), - ); - } else { - return const CircularProgressIndicator(); - } + ), + ); }, ); } - List convertLineToList(String line) { - List values = line.split(" "); - values.removeWhere((element) => element == ""); - return values; - } - - String convertToGB(string) { - double value = double.parse(string); - value /= 100; - int v1 = value.ceil(); - double v2 = v1 / 10; - return "${v2}G"; - } + static String _formatGb(int megabytes) => + "${(megabytes / 1024).toStringAsFixed(1)}G"; } From 49644f65531fece0db716589b2ad922e547f7522 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:59:55 +0000 Subject: [PATCH 3/6] Add the hub: dashboard, sidebar navigation and live tiles Every admin feature already existed as a working screen, but the only way to reach one was to guess its name in the search box, and nothing showed the state of the machine at a glance. Add a persistent shell -- sidebar, top bar, content area -- with sections for Dashboard, Search, Storage, Linux health and Security. Sections are swapped inside the frame rather than pushed: the app's convention is to "go back" by pushing the destination again, which grows the navigator history without bound, and section changes should not add to that. Sections are built on first visit so the search field cannot steal focus while the dashboard is on screen. The dashboard reads from SystemStatsService and shows system identity, CPU, memory, processes and disk usage. Quick actions are looked up in the existing ActionEntry catalog by action code, so their labels, icons and translations stay in one place instead of being redeclared. New widgets follow the Hermes visual language: elevation from a 1px border and a tint rather than a shadow, the 8%/15%/solid tint triad for every badge and active row, a 2px accent spine to mark selection, and opacity as the hierarchy lever for metadata. The sparkline is a CustomPainter -- fl_chart brings axes, legends and hit testing that 60 points on a tile never use. The search keeps working unchanged, including its keyboard navigation. What changes is dismissal: it used to minimize the window and, on Wayland, quit the app five seconds later, because wmctrl cannot raise it again there. That is the right behaviour for a launcher overlay and the wrong one inside a hub, so the hub installs a callback that returns to the dashboard. Super+Q still lands directly in the search box. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HioAo2j6ufA9GsyKBWF69u --- lib/layouts/hub/dashboard_section.dart | 437 ++++++++++++++++++++ lib/layouts/hub/hub_grid.dart | 51 +++ lib/layouts/hub/hub_shell.dart | 359 ++++++++++++++++ lib/layouts/hub/storage_section.dart | 137 ++++++ lib/layouts/main_screen/main_search.dart | 25 +- lib/layouts/updater/updater.dart | 3 +- lib/services/main_search_loader.dart | 23 +- lib/widgets/hermes/hermes_badge.dart | 107 +++++ lib/widgets/hermes/hermes_card.dart | 124 ++++++ lib/widgets/hermes/hermes_copy_command.dart | 103 +++++ lib/widgets/hermes/hermes_halo_dot.dart | 48 +++ lib/widgets/hermes/hermes_nav_item.dart | 114 +++++ lib/widgets/hermes/hermes_sparkline.dart | 110 +++++ lib/widgets/hermes/hermes_stat_tile.dart | 160 +++++++ 14 files changed, 1792 insertions(+), 9 deletions(-) create mode 100644 lib/layouts/hub/dashboard_section.dart create mode 100644 lib/layouts/hub/hub_grid.dart create mode 100644 lib/layouts/hub/hub_shell.dart create mode 100644 lib/layouts/hub/storage_section.dart create mode 100644 lib/widgets/hermes/hermes_badge.dart create mode 100644 lib/widgets/hermes/hermes_card.dart create mode 100644 lib/widgets/hermes/hermes_copy_command.dart create mode 100644 lib/widgets/hermes/hermes_halo_dot.dart create mode 100644 lib/widgets/hermes/hermes_nav_item.dart create mode 100644 lib/widgets/hermes/hermes_sparkline.dart create mode 100644 lib/widgets/hermes/hermes_stat_tile.dart diff --git a/lib/layouts/hub/dashboard_section.dart b/lib/layouts/hub/dashboard_section.dart new file mode 100644 index 0000000..c868c0b --- /dev/null +++ b/lib/layouts/hub/dashboard_section.dart @@ -0,0 +1,437 @@ +import 'package:flutter/material.dart'; +import 'package:linux_assistant/l10n/app_localizations.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; +import 'package:linux_assistant/layouts/hub/hub_grid.dart'; +import 'package:linux_assistant/linux/linux_filesystem.dart'; +import 'package:linux_assistant/models/action_entry.dart'; +import 'package:linux_assistant/services/action_entry_list_service.dart'; +import 'package:linux_assistant/services/action_handler.dart'; +import 'package:linux_assistant/services/linux.dart'; +import 'package:linux_assistant/services/system_stats_service.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_badge.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_card.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_sparkline.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_stat_tile.dart'; + +/// The hub's landing view: what the machine is doing right now, plus the +/// actions people reach for most. +class DashboardSection extends StatefulWidget { + const DashboardSection({super.key, this.onOpenSecurity, this.onOpenStorage}); + + final VoidCallback? onOpenSecurity; + final VoidCallback? onOpenStorage; + + @override + State createState() => _DashboardSectionState(); +} + +class _DashboardSectionState extends State { + /// Action codes surfaced as quick-action tiles. + /// + /// Pulled from the existing catalog by code rather than redeclared here, so + /// the labels, icons and translations stay in one place. + static const List _quickActionCodes = [ + "update_system", + "open_software_center", + "disk_cleaner", + "power_mode", + "open_systeminformation", + "hard_info", + ]; + + final SystemStatsService _service = SystemStatsService(); + List _quickActions = []; + + @override + void initState() { + super.initState(); + _service.acquire(); + _loadQuickActions(); + } + + @override + void dispose() { + _service.release(); + super.dispose(); + } + + Future _loadQuickActions() async { + final entries = await ActionEntryListService.getEntries(); + final byCode = {}; + for (final entry in entries) { + if (_quickActionCodes.contains(entry.action) && + !byCode.containsKey(entry.action)) { + byCode[entry.action] = entry; + } + } + if (!mounted) { + return; + } + setState(() { + _quickActions = [ + for (final code in _quickActionCodes) + if (byCode.containsKey(code)) byCode[code]!, + ]; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final t = HermesTokens.of(context); + + return ValueListenableBuilder( + valueListenable: _service.stats, + builder: (context, stats, _) { + return ListView( + padding: const EdgeInsets.all(HermesTokens.space4), + children: [ + if (stats.error != null) + Padding( + padding: const EdgeInsets.only(bottom: HermesTokens.space3), + child: HermesCard( + spineColor: t.warning, + child: Row( + children: [ + Icon(Icons.warning_amber, size: 16, color: t.warning), + const SizedBox(width: HermesTokens.space2), + Expanded( + child: Text( + l10n.statsUnavailable, + style: TextStyle(color: t.muted, fontSize: 12), + ), + ), + ], + ), + ), + ), + _identityCard(context, stats), + const SizedBox(height: HermesTokens.space4), + HubGrid( + children: [ + _cpuTile(context, stats), + _memoryTile(context, stats), + _processesTile(context, stats), + _storageTile(context, stats), + ], + ), + const SizedBox(height: HermesTokens.space4), + HermesSectionHeader(text: l10n.quickActions), + _quickActionsGrid(context), + ], + ); + }, + ); + } + + Widget _identityCard(BuildContext context, SystemStats stats) { + final l10n = AppLocalizations.of(context)!; + final t = HermesTokens.of(context); + final env = Linux.currentenvironment; + final uptime = stats.uptime; + + return HermesCard( + spineColor: t.accent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + "${env.username}@${env.hostname}", + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: t.strong, + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + ), + if (uptime != null) + HermesBadge( + icon: Icons.schedule, + tone: HermesTone.accent, + text: "${l10n.uptimeLabel} " + "${uptime.value}${_uptimeUnit(context, uptime.unit)}", + ), + ], + ), + const SizedBox(height: HermesTokens.space3), + Wrap( + spacing: HermesTokens.space4, + runSpacing: HermesTokens.space2, + children: [ + _fact(context, Icons.info_outline, env.osPrettyName), + _fact(context, Icons.settings, + "${l10n.kernelLabel} ${env.kernelVersion}"), + _fact(context, Icons.desktop_windows, + "${l10n.desktopLabel} ${env.desktop.name}"), + _fact(context, Icons.memory, env.cpuModel), + _fact(context, Icons.monitor, env.gpuModel), + ], + ), + ], + ), + ); + } + + Widget _fact(BuildContext context, IconData icon, String text) { + final t = HermesTokens.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: t.accent), + const SizedBox(width: HermesTokens.space1), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Text( + text, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: t.muted, fontSize: 12), + ), + ), + ], + ); + } + + Widget _cpuTile(BuildContext context, SystemStats stats) { + final l10n = AppLocalizations.of(context)!; + final t = HermesTokens.of(context); + final percent = (stats.cpuLoad * 100); + + return HermesStatTile( + label: l10n.cpuUsage, + icon: Icons.speed, + value: stats.hasData ? percent.toStringAsFixed(0) : null, + unit: "%", + tone: percent >= 90 ? HermesTone.error : HermesTone.accent, + visual: HermesSparkline( + values: stats.cpuHistory, + color: percent >= 90 ? t.error : t.accent, + ), + footer: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final process in stats.topByCpu.take(3)) + HermesMetaRow( + label: process.processName, + value: "${process.metricValue}%", + ), + ], + ), + ); + } + + Widget _memoryTile(BuildContext context, SystemStats stats) { + final l10n = AppLocalizations.of(context)!; + final t = HermesTokens.of(context); + final memory = stats.memory; + final ratio = memory?.usedRatio ?? 0; + + return HermesStatTile( + label: l10n.memoryUsage, + icon: Icons.developer_board, + value: memory != null ? (ratio * 100).toStringAsFixed(0) : null, + unit: "%", + tone: ratio >= 0.9 ? HermesTone.warning : HermesTone.accent, + badge: memory != null + ? HermesBadge( + dense: true, + text: "${_gb(memory.usedMb)} / ${_gb(memory.totalMb)}", + ) + : null, + visual: HermesSparkline( + values: stats.memoryHistory, + color: ratio >= 0.9 ? t.warning : t.accent, + ), + footer: memory != null && memory.hasSwap + ? HermesMetaRow( + label: l10n.swapLabel, + value: + "${_gb(memory.swapUsedMb)} / ${_gb(memory.swapTotalMb)}", + ) + : null, + ); + } + + Widget _processesTile(BuildContext context, SystemStats stats) { + final l10n = AppLocalizations.of(context)!; + + return HermesStatTile( + label: l10n.processesLabel, + icon: Icons.account_tree, + value: stats.hasData ? "${stats.processCount}" : null, + tone: stats.zombieCount > 0 ? HermesTone.warning : HermesTone.accent, + badge: stats.zombieCount > 0 + ? HermesBadge( + dense: true, + tone: HermesTone.warning, + text: "${stats.zombieCount} ${l10n.zombiesLabel}", + ) + : null, + footer: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final process in stats.topByMemory.take(3)) + HermesMetaRow( + label: process.processName, + value: "${process.metricValue}%", + ), + ], + ), + ); + } + + Widget _storageTile(BuildContext context, SystemStats stats) { + final l10n = AppLocalizations.of(context)!; + final t = HermesTokens.of(context); + + final DeviceInfo? worst = stats.disks.isEmpty + ? null + : stats.disks.reduce( + (a, b) => a.usedPercent >= b.usedPercent ? a : b, + ); + final bool critical = (worst?.usedPercent ?? 0) > 90; + + return HermesStatTile( + label: l10n.diskUsage, + icon: Icons.storage, + value: worst != null ? "${worst.usedPercent}" : null, + unit: "%", + tone: critical ? HermesTone.error : HermesTone.accent, + onTap: widget.onOpenStorage, + badge: worst != null + ? HermesBadge(dense: true, text: worst.mountPoint) + : null, + visual: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final disk in stats.disks.take(3)) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: _diskBar(context, disk, t), + ), + ], + ), + ); + } + + Widget _diskBar(BuildContext context, DeviceInfo disk, HermesTokens t) { + final ratio = (disk.usedPercent / 100).clamp(0.0, 1.0); + final color = disk.usedPercent > 90 ? t.error : t.accent; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + disk.mountPoint, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: t.muted, fontSize: 11), + ), + ), + Text( + "${disk.sizeFree} free", + style: TextStyle(color: t.muted, fontSize: 11), + ), + ], + ), + const SizedBox(height: 3), + ClipRRect( + borderRadius: BorderRadius.circular(HermesTokens.radiusPill), + child: LinearProgressIndicator( + value: ratio, + minHeight: 4, + backgroundColor: t.surfaceSubtleHover, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ], + ); + } + + Widget _quickActionsGrid(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final t = HermesTokens.of(context); + + if (_quickActions.isEmpty) { + return Text( + l10n.noQuickActions, + style: TextStyle(color: t.muted, fontSize: 12), + ); + } + + return HubGrid( + minTileWidth: 220, + children: [ + for (final entry in _quickActions) + HermesCard( + padding: const EdgeInsets.all(HermesTokens.space3), + onTap: () => ActionHandler.handleActionEntry( + entry, + () {}, + context, + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: t.accentBg, + borderRadius: + BorderRadius.circular(HermesTokens.radiusSm), + ), + child: Icon(Icons.bolt, size: 16, color: t.accentText), + ), + const SizedBox(width: HermesTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + entry.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: t.text, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + if (entry.description.isNotEmpty) + Text( + entry.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: t.muted, fontSize: 11), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } + + static String _gb(int megabytes) => + "${(megabytes / 1024).toStringAsFixed(1)}G"; + + String _uptimeUnit(BuildContext context, String unit) { + final l10n = AppLocalizations.of(context)!; + switch (unit) { + case "m": + return l10n.uptimeUnitShortMinutes; + case "h": + return l10n.uptimeUnitShortHours; + default: + return l10n.uptimeUnitShortDays; + } + } +} diff --git a/lib/layouts/hub/hub_grid.dart b/lib/layouts/hub/hub_grid.dart new file mode 100644 index 0000000..fa5a2ef --- /dev/null +++ b/lib/layouts/hub/hub_grid.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; + +/// A responsive tile grid. +/// +/// [MintYGrid] centers fixed-size cards and pads the last row, which suits a +/// launcher's icon wall. Dashboard tiles instead want to stretch to fill the +/// available width, so this computes a column count and lets each cell grow. +class HubGrid extends StatelessWidget { + const HubGrid({ + super.key, + required this.children, + this.minTileWidth = 260, + this.spacing = HermesTokens.space4, + }); + + final List children; + final double minTileWidth; + final double spacing; + + @override + Widget build(BuildContext context) { + if (children.isEmpty) { + return const SizedBox.shrink(); + } + + return LayoutBuilder( + builder: (context, constraints) { + final double available = constraints.maxWidth; + int columns = (available / minTileWidth).floor(); + columns = columns.clamp(1, 4); + + final double tileWidth = + (available - spacing * (columns - 1)) / columns; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final child in children) + SizedBox( + // Guard against a negative width in very narrow layouts. + width: tileWidth > 0 ? tileWidth : available, + child: child, + ), + ], + ); + }, + ); + } +} diff --git a/lib/layouts/hub/hub_shell.dart b/lib/layouts/hub/hub_shell.dart new file mode 100644 index 0000000..bccf503 --- /dev/null +++ b/lib/layouts/hub/hub_shell.dart @@ -0,0 +1,359 @@ +import 'package:flutter/material.dart'; +import 'package:linux_assistant/l10n/app_localizations.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; +import 'package:linux_assistant/layouts/hub/dashboard_section.dart'; +import 'package:linux_assistant/layouts/hub/storage_section.dart'; +import 'package:linux_assistant/layouts/linux_health/overview.dart'; +import 'package:linux_assistant/layouts/main_screen/main_search.dart'; +import 'package:linux_assistant/layouts/security_check/overview.dart'; +import 'package:linux_assistant/layouts/settings/settings_start.dart'; +import 'package:linux_assistant/main.dart'; +import 'package:linux_assistant/services/system_stats_service.dart'; +import 'package:linux_assistant/services/theme_controller.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_nav_item.dart'; + +/// The sections reachable from the sidebar. +enum HubSection { dashboard, search, storage, health, security } + +/// The persistent frame of the hub: sidebar, top bar and the active section. +/// +/// Sections are swapped inside the frame rather than pushed onto the +/// [Navigator]. The app's existing convention is to "go back" by pushing the +/// destination again, which grows the history without bound; keeping section +/// changes out of the navigator avoids adding to that. +class HubShell extends StatefulWidget { + const HubShell({super.key, this.initialSection = HubSection.dashboard}); + + final HubSection initialSection; + + /// Set while a hub is mounted, so the global hotkey can jump straight to the + /// search section instead of only raising the window. + static VoidCallback? onSearchRequested; + + @override + State createState() => _HubShellState(); +} + +class _HubShellState extends State { + static const double _sidebarWidth = 260; + static const double _railWidth = 56; + + /// Below this width the sidebar collapses to an icon rail. + static const double _collapseBreakpoint = 1000; + + late HubSection _section = widget.initialSection; + + /// Sections are created on first visit and then kept alive. Building them all + /// up front would start every section's polling and let the search field + /// steal focus while the dashboard is on screen. + final Map _built = {}; + + @override + void initState() { + super.initState(); + // While the hub is open, dismissing a search result should return here + // rather than minimizing the window. + MainSearch.onDismiss = _returnToDashboard; + HubShell.onSearchRequested = _focusSearch; + } + + @override + void dispose() { + if (MainSearch.onDismiss == _returnToDashboard) { + MainSearch.onDismiss = null; + } + if (HubShell.onSearchRequested == _focusSearch) { + HubShell.onSearchRequested = null; + } + super.dispose(); + } + + void _focusSearch() { + if (mounted) { + setState(() => _section = HubSection.search); + } + } + + void _returnToDashboard() { + if (mounted) { + setState(() => _section = HubSection.dashboard); + } + } + + void _select(HubSection section) { + if (_section != section) { + setState(() => _section = section); + } + } + + String _titleOf(BuildContext context, HubSection section) { + final l10n = AppLocalizations.of(context)!; + switch (section) { + case HubSection.dashboard: + return l10n.dashboard; + case HubSection.search: + return l10n.hubSearch; + case HubSection.storage: + return l10n.hubStorage; + case HubSection.health: + return l10n.linuxHealth; + case HubSection.security: + return l10n.securityCheck; + } + } + + IconData _iconOf(HubSection section) { + switch (section) { + case HubSection.dashboard: + return Icons.dashboard_outlined; + case HubSection.search: + return Icons.search; + case HubSection.storage: + return Icons.storage; + case HubSection.health: + return Icons.favorite_outline; + case HubSection.security: + return Icons.shield_outlined; + } + } + + Widget _buildSection(HubSection section) { + switch (section) { + case HubSection.dashboard: + return DashboardSection( + onOpenStorage: () => _select(HubSection.storage), + onOpenSecurity: () => _select(HubSection.security), + ); + case HubSection.search: + return MainSearch(); + case HubSection.storage: + return const StorageSection(); + case HubSection.health: + return const Padding( + padding: EdgeInsets.all(HermesTokens.space4), + child: LinuxHealthContent(), + ); + case HubSection.security: + return const SecurityCheckContent(); + } + } + + @override + Widget build(BuildContext context) { + final t = HermesTokens.of(context); + + return LayoutBuilder( + builder: (context, constraints) { + final bool collapsed = constraints.maxWidth < _collapseBreakpoint; + + return Scaffold( + backgroundColor: t.bg, + body: Row( + children: [ + _sidebar(context, t, collapsed), + Expanded( + child: Column( + children: [ + _topBar(context, t), + Expanded(child: _content()), + ], + ), + ), + ], + ), + ); + }, + ); + } + + Widget _content() { + _built.putIfAbsent(_section, () => _buildSection(_section)); + final visited = _built.keys.toList(); + + return IndexedStack( + index: visited.indexOf(_section), + children: [ + for (final section in visited) + // TickerMode stops animations and the search field's timers in + // sections that are currently off screen. + TickerMode( + enabled: section == _section, + child: _built[section]!, + ), + ], + ); + } + + Widget _sidebar(BuildContext context, HermesTokens t, bool collapsed) { + final l10n = AppLocalizations.of(context)!; + + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: collapsed ? _railWidth : _sidebarWidth, + decoration: BoxDecoration( + color: t.sidebar, + border: Border( + right: BorderSide(color: t.border, width: HermesTokens.borderWidth), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _brand(context, t, collapsed), + Divider(color: t.borderSubtle, height: 1), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric( + horizontal: HermesTokens.space2, + vertical: HermesTokens.space2, + ), + children: [ + for (final section in HubSection.values) + HermesNavItem( + icon: _iconOf(section), + label: _titleOf(context, section), + selected: _section == section, + collapsed: collapsed, + onTap: () => _select(section), + ), + ], + ), + ), + Divider(color: t.borderSubtle, height: 1), + Padding( + padding: const EdgeInsets.all(HermesTokens.space2), + child: HermesNavItem( + icon: Icons.settings_outlined, + label: l10n.settings, + selected: false, + collapsed: collapsed, + onTap: () => showDialog( + context: context, + builder: (context) => const SettingsStart(), + ), + ), + ), + ], + ), + ); + } + + Widget _brand(BuildContext context, HermesTokens t, bool collapsed) { + final logo = Container( + width: 32, + height: 32, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [t.accentHover, t.accent], + ), + borderRadius: BorderRadius.circular(9), + ), + alignment: Alignment.center, + child: Text( + "LA", + style: TextStyle( + color: t.onAccent, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + ); + + return Padding( + padding: const EdgeInsets.all(HermesTokens.space3), + child: collapsed + ? Center(child: logo) + : Row( + children: [ + logo, + const SizedBox(width: HermesTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Linux Assistant", + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: t.strong, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + if (CURRENT_LINUX_ASSISTANT_VERSION.isNotEmpty) + Text( + "v$CURRENT_LINUX_ASSISTANT_VERSION", + style: TextStyle(color: t.muted, fontSize: 11), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _topBar(BuildContext context, HermesTokens t) { + final l10n = AppLocalizations.of(context)!; + final controller = ThemeController(); + + return Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: HermesTokens.space4), + decoration: BoxDecoration( + color: t.sidebar, + border: Border( + bottom: BorderSide(color: t.border, width: HermesTokens.borderWidth), + ), + ), + child: Row( + children: [ + Text( + _titleOf(context, _section), + style: TextStyle( + color: t.strong, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (_section != HubSection.search) + IconButton( + onPressed: () => _select(HubSection.search), + icon: const Icon(Icons.search, size: 18), + color: t.muted, + tooltip: l10n.hubSearch, + ), + IconButton( + onPressed: () => SystemStatsService().refresh(), + icon: const Icon(Icons.refresh, size: 18), + color: t.muted, + tooltip: l10n.reload, + ), + IconButton( + onPressed: () async { + await controller.cycleThemeMode(); + }, + icon: Icon(_themeIcon(controller.themeMode), size: 18), + color: t.muted, + tooltip: l10n.switchTheme, + ), + ], + ), + ); + } + + static IconData _themeIcon(ThemeMode mode) { + switch (mode) { + case ThemeMode.light: + return Icons.light_mode_outlined; + case ThemeMode.dark: + return Icons.dark_mode_outlined; + case ThemeMode.system: + return Icons.brightness_auto_outlined; + } + } +} diff --git a/lib/layouts/hub/storage_section.dart b/lib/layouts/hub/storage_section.dart new file mode 100644 index 0000000..a5869f7 --- /dev/null +++ b/lib/layouts/hub/storage_section.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:linux_assistant/l10n/app_localizations.dart'; +import 'package:linux_assistant/layouts/disk_cleaner/cleaner_select_disk.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; +import 'package:linux_assistant/linux/linux_filesystem.dart'; +import 'package:linux_assistant/services/system_stats_service.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_badge.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_card.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_stat_tile.dart'; + +/// Every mounted filesystem with its fill level, plus the way into the disk +/// cleaner. +class StorageSection extends StatefulWidget { + const StorageSection({super.key}); + + @override + State createState() => _StorageSectionState(); +} + +class _StorageSectionState extends State { + final SystemStatsService _service = SystemStatsService(); + + @override + void initState() { + super.initState(); + _service.acquire(); + } + + @override + void dispose() { + _service.release(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final t = HermesTokens.of(context); + + return ValueListenableBuilder( + valueListenable: _service.stats, + builder: (context, stats, _) { + if (!stats.hasData) { + return Center( + child: Text( + l10n.collectingData, + style: TextStyle(color: t.muted), + ), + ); + } + + return ListView( + padding: const EdgeInsets.all(HermesTokens.space4), + children: [ + HermesSectionHeader( + text: l10n.mountedDisks, + trailing: TextButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const CleanerSelectDiskPage(), + ), + ), + icon: const Icon(Icons.cleaning_services, size: 14), + label: Text(l10n.cleanDiskspace), + ), + ), + for (final disk in stats.disks) + Padding( + padding: const EdgeInsets.only(bottom: HermesTokens.space3), + child: _diskCard(context, disk, t), + ), + ], + ); + }, + ); + } + + Widget _diskCard(BuildContext context, DeviceInfo disk, HermesTokens t) { + final bool critical = disk.usedPercent > 90; + final bool warning = !critical && disk.usedPercent > 75; + final Color color = + critical ? t.error : (warning ? t.warning : t.accent); + + return HermesCard( + spineColor: critical ? t.error : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + disk.isRemovable ? Icons.usb : Icons.storage, + size: 16, + color: color, + ), + const SizedBox(width: HermesTokens.space2), + Expanded( + child: Text( + disk.mountPoint, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: t.strong, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + HermesBadge( + dense: true, + tone: critical + ? HermesTone.error + : (warning ? HermesTone.warning : HermesTone.neutral), + text: "${disk.usedPercent}%", + ), + ], + ), + const SizedBox(height: HermesTokens.space2), + ClipRRect( + borderRadius: BorderRadius.circular(HermesTokens.radiusPill), + child: LinearProgressIndicator( + value: (disk.usedPercent / 100).clamp(0.0, 1.0), + minHeight: 6, + backgroundColor: t.surfaceSubtleHover, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + const SizedBox(height: HermesTokens.space2), + HermesMetaRow(label: disk.filesystem, value: disk.size), + HermesMetaRow( + label: AppLocalizations.of(context)!.diskUsage, + value: "${disk.sizeUsed} / ${disk.size}", + ), + ], + ), + ); + } +} diff --git a/lib/layouts/main_screen/main_search.dart b/lib/layouts/main_screen/main_search.dart index 859a258..a271abd 100644 --- a/lib/layouts/main_screen/main_search.dart +++ b/lib/layouts/main_screen/main_search.dart @@ -37,6 +37,15 @@ class MainSearch extends StatefulWidget { ); } + /// Set by the hub while it is on screen. + /// + /// The search was built as a launcher overlay: finishing an action minimizes + /// the window, and on Wayland quits the app a few seconds later, because + /// `wmctrl -a` cannot raise it again there. Inside a hub that is the wrong + /// behaviour — there is a window to go back to — so the hub installs a + /// callback that returns to the dashboard instead. + static VoidCallback? onDismiss; + @override State createState() => _MainSearchState(); @@ -387,12 +396,16 @@ class _MainSearchState extends State { void clear({minimze = true}) { if (minimze) { - /// On wayland sessions we currently can't issue 'wmctrl -a' - /// So if we want to get the hotkey working we need to close the app after - /// a single use. Because otherwise everytime the user presses the hotkey - /// an additional window would open. - /// On x11 sessions we don't have the issue. - if (Linux.currentenvironment.wayland) { + final VoidCallback? returnToHub = MainSearch.onDismiss; + if (returnToHub != null) { + // Running inside the hub: keep the window open and hand control back. + returnToHub(); + } else if (Linux.currentenvironment.wayland) { + /// On wayland sessions we currently can't issue 'wmctrl -a' + /// So if we want to get the hotkey working we need to close the app + /// after a single use. Because otherwise everytime the user presses + /// the hotkey an additional window would open. + /// On x11 sessions we don't have the issue. windowManager.minimize(); Future.delayed(const Duration(seconds: 5), () => exit(0)); } else { diff --git a/lib/layouts/updater/updater.dart b/lib/layouts/updater/updater.dart index 23c5086..347e73f 100644 --- a/lib/layouts/updater/updater.dart +++ b/lib/layouts/updater/updater.dart @@ -68,7 +68,8 @@ class LinuxAssistantUpdatePage extends StatelessWidget { ); } else if (snapshot.connectionState == ConnectionState.done || snapshot.hasError) { - return const MainSearchLoader(); + // This is where startup lands, so it is what opens the hub. + return const MainSearchLoader(destination: LoaderDestination.hub); } else { return const MintYLoadingPage(); } diff --git a/lib/services/main_search_loader.dart b/lib/services/main_search_loader.dart index e715ffc..9725724 100644 --- a/lib/services/main_search_loader.dart +++ b/lib/services/main_search_loader.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:linux_assistant/content/basic_entries.dart'; import 'package:linux_assistant/content/recommendations.dart'; +import 'package:linux_assistant/layouts/hub/hub_shell.dart'; import 'package:linux_assistant/layouts/main_screen/main_search.dart'; import 'package:linux_assistant/layouts/mint_y.dart'; import 'package:linux_assistant/models/action_entry.dart'; @@ -11,8 +12,24 @@ import 'package:linux_assistant/services/linux.dart'; import 'package:linux_assistant/l10n/app_localizations.dart'; import 'package:logger/logger.dart'; +/// What the loader shows once the action catalog is ready. +enum LoaderDestination { + /// The launcher-style search screen. + search, + + /// The hub, with the dashboard selected. + hub, +} + class MainSearchLoader extends StatefulWidget { - const MainSearchLoader({Key? key}) : super(key: key); + const MainSearchLoader({ + super.key, + this.destination = LoaderDestination.search, + }); + + /// Defaults to [LoaderDestination.search] so the many existing "back to + /// search" buttons keep behaving exactly as before. + final LoaderDestination destination; @override State createState() => _MainSearchLoaderState(); @@ -174,7 +191,9 @@ class _MainSearchLoaderState extends State { future: futureVoid, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { - return (MainSearch()); + return widget.destination == LoaderDestination.hub + ? const HubShell() + : MainSearch(); } else { return MintYLoadingPage( text: AppLocalizations.of(context)!.preparingSearch); diff --git a/lib/widgets/hermes/hermes_badge.dart b/lib/widgets/hermes/hermes_badge.dart new file mode 100644 index 0000000..3d69ce0 --- /dev/null +++ b/lib/widgets/hermes/hermes_badge.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; + +/// Semantic role of a badge or status dot. +enum HermesTone { neutral, accent, success, warning, error, info } + +/// The three colors a tone resolves to. +@immutable +class HermesToneColors { + const HermesToneColors({ + required this.fg, + required this.bg, + required this.border, + }); + + final Color fg; + final Color bg; + final Color border; +} + +/// Resolves a tone to its foreground, tinted background and border. +/// +/// This is the one place the Hermes tint formula lives: background at ~10%, +/// border at ~28%, solid color as text. +HermesToneColors hermesToneColors(HermesTokens t, HermesTone tone) { + Color base; + switch (tone) { + case HermesTone.neutral: + return HermesToneColors( + fg: t.muted, bg: t.surfaceSubtle, border: t.border); + case HermesTone.accent: + return HermesToneColors( + fg: t.accentText, bg: t.accentBg, border: t.accentBgStrong); + case HermesTone.success: + base = t.success; + break; + case HermesTone.warning: + base = t.warning; + break; + case HermesTone.error: + base = t.error; + break; + case HermesTone.info: + base = t.info; + break; + } + return HermesToneColors( + fg: base, + bg: Color.alphaBlend(base.withValues(alpha: 0.10), t.bg), + border: Color.alphaBlend(base.withValues(alpha: 0.28), t.bg), + ); +} + +/// A pill: tinted background, hairline border, solid semantic text. +class HermesBadge extends StatelessWidget { + const HermesBadge({ + super.key, + required this.text, + this.tone = HermesTone.neutral, + this.icon, + this.dense = false, + }); + + final String text; + final HermesTone tone; + final IconData? icon; + + /// Tighter padding, for badges sitting inside a table row. + final bool dense; + + @override + Widget build(BuildContext context) { + final t = HermesTokens.of(context); + final colors = hermesToneColors(t, tone); + + return Container( + padding: EdgeInsets.symmetric( + horizontal: dense ? 6 : 10, + vertical: dense ? 1 : 3, + ), + decoration: BoxDecoration( + color: colors.bg, + border: Border.all( + color: colors.border, width: HermesTokens.borderWidth), + borderRadius: BorderRadius.circular(HermesTokens.radiusPill), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: dense ? 10 : 12, color: colors.fg), + const SizedBox(width: 4), + ], + Text( + text, + style: TextStyle( + color: colors.fg, + fontSize: dense ? 10 : 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.04 * 11, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/hermes/hermes_card.dart b/lib/widgets/hermes/hermes_card.dart new file mode 100644 index 0000000..a867fee --- /dev/null +++ b/lib/widgets/hermes/hermes_card.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; + +/// The base surface of the hub. +/// +/// Hermes expresses elevation with a 1px border and a tint rather than with a +/// drop shadow — its stylesheet sets `box-shadow: none` in 23 places. Keeping +/// that here is what makes a screen full of these read as one system instead of +/// a pile of Material cards. +class HermesCard extends StatelessWidget { + const HermesCard({ + super.key, + required this.child, + this.padding = const EdgeInsets.all(HermesTokens.space4), + this.onTap, + this.spineColor, + this.background, + this.width, + this.height, + }); + + final Widget child; + final EdgeInsetsGeometry padding; + final VoidCallback? onTap; + + /// When set, draws the 2px accent spine along the left edge. Used to mark an + /// active or noteworthy card. + final Color? spineColor; + + final Color? background; + final double? width; + final double? height; + + @override + Widget build(BuildContext context) { + final t = HermesTokens.of(context); + final radius = BorderRadius.circular(HermesTokens.radiusMd); + + Widget content = Padding(padding: padding, child: child); + + if (onTap != null) { + content = InkWell( + onTap: onTap, + borderRadius: radius, + hoverColor: t.hoverBg, + child: content, + ); + } + + return Container( + width: width, + height: height, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: background ?? t.surface, + borderRadius: radius, + // Uniform on purpose: Flutter rejects a rounded border whose sides + // differ in color, so the accent spine is painted as an overlay below + // rather than as a thicker left side. + border: Border.all(color: t.border, width: HermesTokens.borderWidth), + ), + child: Stack( + children: [ + Material( + color: Colors.transparent, + child: content, + ), + if (spineColor != null) + Positioned( + left: 0, + top: 0, + bottom: 0, + width: HermesTokens.spineWidth, + child: IgnorePointer( + child: ColoredBox(color: spineColor!), + ), + ), + ], + ), + ); + } +} + +/// A small uppercase label above a group of cards. +class HermesSectionHeader extends StatelessWidget { + const HermesSectionHeader({ + super.key, + required this.text, + this.trailing, + }); + + final String text; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final t = HermesTokens.of(context); + return Padding( + padding: const EdgeInsets.only( + bottom: HermesTokens.space3, + top: HermesTokens.space2, + ), + child: Row( + children: [ + Text( + text.toUpperCase(), + style: TextStyle( + color: t.muted, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.08 * 11, + ), + ), + const SizedBox(width: HermesTokens.space3), + Expanded(child: Divider(color: t.borderSubtle, height: 1)), + if (trailing != null) ...[ + const SizedBox(width: HermesTokens.space3), + trailing!, + ], + ], + ), + ); + } +} diff --git a/lib/widgets/hermes/hermes_copy_command.dart b/lib/widgets/hermes/hermes_copy_command.dart new file mode 100644 index 0000000..95ce68c --- /dev/null +++ b/lib/widgets/hermes/hermes_copy_command.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:linux_assistant/l10n/app_localizations.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; + +/// A monospace command line with a copy button. +/// +/// This is how the read-only areas of the hub offer remediation: the app shows +/// what to run and hands it over, the user decides whether to run it. Nothing +/// here executes anything. +class HermesCopyCommand extends StatefulWidget { + const HermesCopyCommand({ + super.key, + required this.command, + this.caption, + }); + + final String command; + + /// Optional line above the command explaining what it does. + final String? caption; + + @override + State createState() => _HermesCopyCommandState(); +} + +class _HermesCopyCommandState extends State { + bool _copied = false; + + Future _copy() async { + await Clipboard.setData(ClipboardData(text: widget.command)); + if (!mounted) { + return; + } + setState(() => _copied = true); + // Revert the confirmation after a moment so the button stays reusable. + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() => _copied = false); + } + }); + } + + @override + Widget build(BuildContext context) { + final t = HermesTokens.of(context); + final l10n = AppLocalizations.of(context)!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.caption != null) ...[ + Text( + widget.caption!, + style: TextStyle(color: t.muted, fontSize: 12), + ), + const SizedBox(height: HermesTokens.space1), + ], + Container( + decoration: BoxDecoration( + color: t.codeBg, + borderRadius: BorderRadius.circular(HermesTokens.radiusSm), + border: + Border.all(color: t.border, width: HermesTokens.borderWidth), + ), + padding: const EdgeInsets.only( + left: HermesTokens.space3, + top: HermesTokens.space2, + bottom: HermesTokens.space2, + right: HermesTokens.space1, + ), + child: Row( + children: [ + Expanded( + child: SelectableText( + widget.command, + style: TextStyle( + color: t.codeText, + fontFamily: HermesTokens.fontMono, + fontSize: 12.5, + ), + ), + ), + const SizedBox(width: HermesTokens.space2), + Tooltip( + message: _copied ? l10n.copied : l10n.copyCommand, + child: IconButton( + onPressed: _copy, + visualDensity: VisualDensity.compact, + iconSize: 16, + splashRadius: 16, + color: _copied ? t.success : t.muted, + icon: Icon(_copied ? Icons.check : Icons.copy), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/hermes/hermes_halo_dot.dart b/lib/widgets/hermes/hermes_halo_dot.dart new file mode 100644 index 0000000..04bf610 --- /dev/null +++ b/lib/widgets/hermes/hermes_halo_dot.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_badge.dart'; + +/// A 7px status dot wrapped in a soft halo. +/// +/// Hermes draws this as `box-shadow: 0 0 0 3px `; here the halo is an +/// outer container, which also keeps the dot's hit area comfortable. +class HermesHaloDot extends StatelessWidget { + const HermesHaloDot({ + super.key, + this.tone = HermesTone.accent, + this.size = 7, + this.haloWidth = 3, + }); + + final HermesTone tone; + final double size; + final double haloWidth; + + @override + Widget build(BuildContext context) { + final t = HermesTokens.of(context); + final colors = hermesToneColors(t, tone); + final total = size + haloWidth * 2; + + return SizedBox( + width: total, + height: total, + child: Center( + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colors.fg, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: colors.border, + spreadRadius: haloWidth, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/hermes/hermes_nav_item.dart b/lib/widgets/hermes/hermes_nav_item.dart new file mode 100644 index 0000000..592dc8f --- /dev/null +++ b/lib/widgets/hermes/hermes_nav_item.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; + +/// A sidebar row. +/// +/// Follows the Hermes session-item motif: muted when idle, full text color on +/// hover, and an accent tint plus a 2px spine when active. +class HermesNavItem extends StatefulWidget { + const HermesNavItem({ + super.key, + required this.icon, + required this.label, + required this.selected, + required this.onTap, + this.trailing, + this.collapsed = false, + }); + + final IconData icon; + final String label; + final bool selected; + final VoidCallback onTap; + + /// Optional badge, e.g. a pending-update count. + final Widget? trailing; + + /// Icon-only rail mode for narrow windows. + final bool collapsed; + + @override + State createState() => _HermesNavItemState(); +} + +class _HermesNavItemState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final t = HermesTokens.of(context); + final selected = widget.selected; + + final Color foreground = selected + ? t.accentText + : _hovered + ? t.text + : t.muted; + + final Color background = selected + ? t.accentBg + : _hovered + ? t.surfaceSubtleHover + : Colors.transparent; + + final row = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.icon, size: 18, color: foreground), + if (!widget.collapsed) ...[ + const SizedBox(width: HermesTokens.space3), + Expanded( + child: Text( + widget.label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: foreground, + fontSize: 13, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + if (widget.trailing != null) widget.trailing!, + ], + ], + ); + + return Semantics( + selected: selected, + button: true, + label: widget.label, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.symmetric(vertical: 1), + padding: EdgeInsets.only( + left: widget.collapsed ? 0 : HermesTokens.space3, + right: widget.collapsed ? 0 : HermesTokens.space2, + top: HermesTokens.space2, + bottom: HermesTokens.space2, + ), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(HermesTokens.radiusMd), + border: Border( + left: BorderSide( + color: selected ? t.accent : Colors.transparent, + width: HermesTokens.spineWidth, + ), + ), + ), + child: widget.collapsed + ? Center(child: row) + : SizedBox(width: double.infinity, child: row), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/hermes/hermes_sparkline.dart b/lib/widgets/hermes/hermes_sparkline.dart new file mode 100644 index 0000000..e95f3d8 --- /dev/null +++ b/lib/widgets/hermes/hermes_sparkline.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; + +/// A minimal line chart for a rolling series. +/// +/// Deliberately a [CustomPainter] rather than an fl_chart widget: this draws at +/// most 60 points with no axes, legend, tooltip or hit testing, and a chart +/// library brings a lot of machinery that would go unused on every tile. +class HermesSparkline extends StatelessWidget { + const HermesSparkline({ + super.key, + required this.values, + this.color, + this.height = 36, + this.maxValue = 1.0, + }); + + /// Oldest first. Fewer than two points renders as empty space. + final List values; + final Color? color; + final double height; + + /// Upper bound of the value axis; values above it are clamped. + final double maxValue; + + @override + Widget build(BuildContext context) { + final t = HermesTokens.of(context); + return SizedBox( + height: height, + width: double.infinity, + child: CustomPaint( + painter: _SparklinePainter( + values: values, + color: color ?? t.accent, + maxValue: maxValue, + ), + ), + ); + } +} + +class _SparklinePainter extends CustomPainter { + _SparklinePainter({ + required this.values, + required this.color, + required this.maxValue, + }); + + final List values; + final Color color; + final double maxValue; + + @override + void paint(Canvas canvas, Size size) { + if (values.length < 2 || size.width <= 0 || size.height <= 0) { + return; + } + + final safeMax = maxValue <= 0 ? 1.0 : maxValue; + final dx = size.width / (values.length - 1); + + double yFor(double value) { + final normalized = (value / safeMax).clamp(0.0, 1.0); + // Inset by the stroke width so the extremes are not clipped. + return size.height - 1 - normalized * (size.height - 2); + } + + final path = Path()..moveTo(0, yFor(values.first)); + for (int i = 1; i < values.length; i++) { + path.lineTo(dx * i, yFor(values[i])); + } + + final fill = Path.from(path) + ..lineTo(size.width, size.height) + ..lineTo(0, size.height) + ..close(); + + canvas.drawPath( + fill, + Paint() + ..style = PaintingStyle.fill + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + color.withValues(alpha: 0.22), + color.withValues(alpha: 0.0), + ], + ).createShader(Offset.zero & size), + ); + + canvas.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..color = color, + ); + } + + @override + bool shouldRepaint(covariant _SparklinePainter oldDelegate) { + return oldDelegate.color != color || + oldDelegate.maxValue != maxValue || + !identical(oldDelegate.values, values); + } +} diff --git a/lib/widgets/hermes/hermes_stat_tile.dart b/lib/widgets/hermes/hermes_stat_tile.dart new file mode 100644 index 0000000..03249b1 --- /dev/null +++ b/lib/widgets/hermes/hermes_stat_tile.dart @@ -0,0 +1,160 @@ +import 'dart:ui' show FontFeature; + +import 'package:flutter/material.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_badge.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_card.dart'; + +/// A dashboard tile: small label, large value, optional visual and footer. +/// +/// The type hierarchy follows the Hermes scale — 11px uppercase metadata, +/// oversized value, 12px supporting rows — so tiles stay scannable at a glance. +class HermesStatTile extends StatelessWidget { + const HermesStatTile({ + super.key, + required this.label, + required this.icon, + this.value, + this.unit, + this.tone = HermesTone.accent, + this.badge, + this.visual, + this.footer, + this.onTap, + }); + + final String label; + final IconData icon; + + /// The headline number. Null renders a placeholder dash while loading. + final String? value; + final String? unit; + final HermesTone tone; + final Widget? badge; + + /// Sparkline, bar or any other compact graphic under the value. + final Widget? visual; + + /// Rows below the visual, e.g. top processes. + final Widget? footer; + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final t = HermesTokens.of(context); + final colors = hermesToneColors(t, tone); + + return HermesCard( + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(icon, size: 14, color: colors.fg), + const SizedBox(width: HermesTokens.space2), + Expanded( + child: Text( + label.toUpperCase(), + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: t.muted, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.08 * 11, + ), + ), + ), + if (badge != null) badge!, + ], + ), + const SizedBox(height: HermesTokens.space3), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Flexible( + child: Text( + value ?? "—", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: t.strong, + fontSize: 28, + fontWeight: FontWeight.w500, + height: 1.1, + ), + ), + ), + if (unit != null) ...[ + const SizedBox(width: 4), + Text( + unit!, + style: TextStyle(color: t.muted, fontSize: 13), + ), + ], + ], + ), + if (visual != null) ...[ + const SizedBox(height: HermesTokens.space3), + visual!, + ], + if (footer != null) ...[ + const SizedBox(height: HermesTokens.space3), + Divider(color: t.borderSubtle, height: 1), + const SizedBox(height: HermesTokens.space2), + footer!, + ], + ], + ), + ); + } +} + +/// A compact `label — value` line for tile footers. +class HermesMetaRow extends StatelessWidget { + const HermesMetaRow({ + super.key, + required this.label, + required this.value, + this.valueTone, + }); + + final String label; + final String value; + final Color? valueTone; + + @override + Widget build(BuildContext context) { + final t = HermesTokens.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Expanded( + child: Text( + label, + overflow: TextOverflow.ellipsis, + // Opacity, not a different hue, is how Hermes de-emphasizes. + style: TextStyle( + color: t.muted.withValues(alpha: HermesTokens.opacityStrong), + fontSize: 12, + ), + ), + ), + const SizedBox(width: HermesTokens.space2), + Text( + value, + style: TextStyle( + color: valueTone ?? t.text, + fontSize: 12, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ); + } +} From a3d115eeeb3a7ecaaab23500bb42bbe8b25985e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 13:00:14 +0000 Subject: [PATCH 4/6] Make the security check read-only The security screen offered "Fix" buttons that installed and configured a firewall and rewrote home directory permissions. Inspection and modification are different levels of trust, and this area should only inspect. Each finding now carries a P0-P3 severity, a short line on why it matters, and the command to look closer or to make the change -- which the user runs themselves. Nothing on this screen executes anything; the only interactive control is "copy to clipboard". Findings are sorted most severe first so the important ones are never below the fold, and the section is labelled read-only so the guarantee is visible rather than implied. Linux.installAndConfigureFirewall() and fixHomeFolderPermissions() are left in place; only this screen's calls to them are removed. The findings are also extracted into a widget that the hub embeds, and the checker is no longer re-run by rebuilding the whole page. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HioAo2j6ufA9GsyKBWF69u --- lib/layouts/security_check/overview.dart | 634 ++++++++---------- .../security_check/security_finding.dart | 186 +++++ 2 files changed, 483 insertions(+), 337 deletions(-) create mode 100644 lib/layouts/security_check/security_finding.dart diff --git a/lib/layouts/security_check/overview.dart b/lib/layouts/security_check/overview.dart index 0309c56..b5352e4 100644 --- a/lib/layouts/security_check/overview.dart +++ b/lib/layouts/security_check/overview.dart @@ -2,382 +2,342 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:linux_assistant/enums/distros.dart'; +import 'package:linux_assistant/l10n/app_localizations.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; import 'package:linux_assistant/layouts/mint_y.dart'; -import 'package:linux_assistant/layouts/run_command_queue.dart'; +import 'package:linux_assistant/layouts/security_check/security_finding.dart'; import 'package:linux_assistant/services/linux.dart'; import 'package:linux_assistant/services/main_search_loader.dart'; -import 'package:linux_assistant/l10n/app_localizations.dart'; -import 'package:linux_assistant/widgets/success_message.dart'; -import 'package:linux_assistant/widgets/warning_message.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_badge.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_card.dart'; +/// Standalone security page, reached from search. class SecurityCheckOverview extends StatelessWidget { - const SecurityCheckOverview({Key? key}) : super(key: key); + const SecurityCheckOverview({super.key}); @override Widget build(BuildContext context) { - Future checkerOutputString; + return MintYPage( + title: AppLocalizations.of(context)!.securityCheck, + customContentElement: const Expanded(child: SecurityCheckContent()), + bottom: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MintYButtonNavigate( + route: const MainSearchLoader(), + text: Text(AppLocalizations.of(context)!.backToSearch, + style: MintY.heading4), + color: Colors.white, + ), + ], + ), + ); + } +} + +/// The security findings, read-only. +/// +/// This area inspects and explains; it never changes the system. Where an +/// earlier version offered a "Fix" button that installed a firewall or altered +/// home directory permissions, it now shows the command and leaves the decision +/// — and the execution — with the user. +class SecurityCheckContent extends StatefulWidget { + const SecurityCheckContent({super.key}); + + @override + State createState() => _SecurityCheckContentState(); +} + +class _SecurityCheckContentState extends State { + late Future _checkerOutput = _runChecker(); + + static Future _runChecker() { + final home = "--home=${Platform.environment['HOME']}"; + final distro = Linux.currentenvironment.distribution; - if (Linux.currentenvironment.distribution == DISTROS.OPENSUSE) { - checkerOutputString = Linux.runPythonScript("check_security_opensuse.py", - root: true, - arguments: ["--home=${Platform.environment['HOME']}"], - getErrorMessages: true); - } else if (Linux.currentenvironment.distribution == DISTROS.FEDORA) { - checkerOutputString = Linux.runPythonScript("check_security_fedora.py", - root: true, - arguments: ["--home=${Platform.environment['HOME']}"], - getErrorMessages: true); + String script; + if (distro == DISTROS.OPENSUSE) { + script = "check_security_opensuse.py"; + } else if (distro == DISTROS.FEDORA) { + script = "check_security_fedora.py"; } else if ([DISTROS.ARCH, DISTROS.MANJARO, DISTROS.ENDEAVOUR] - .contains(Linux.currentenvironment.distribution)) { - checkerOutputString = Linux.runPythonScript("check_security_arch.py", - root: true, - arguments: ["--home=${Platform.environment['HOME']}"], - getErrorMessages: true); + .contains(distro)) { + script = "check_security_arch.py"; } else { - checkerOutputString = Linux.runPythonScript("check_security.py", - root: true, - arguments: ["--home=${Platform.environment['HOME']}"], - getErrorMessages: true); + script = "check_security.py"; } + return Linux.runPythonScript(script, + root: true, arguments: [home], getErrorMessages: true); + } + + void _reload() { + setState(() => _checkerOutput = _runChecker()); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return FutureBuilder( - future: checkerOutputString, + future: _checkerOutput, builder: (context, snapshot) { - if (snapshot.hasData) { - print(snapshot.data!); - List lines = snapshot.data!.split("\n"); - if (!snapshot.data!.contains("#!script ran successfully.")) { - return Scaffold( - body: MintYPage( - title: AppLocalizations.of(context)!.securityCheck, - contentElements: [ - Text( - AppLocalizations.of(context)! - .securityCheckFailedBecauseNoRoot, - style: Theme.of(context).textTheme.headlineLarge, - textAlign: TextAlign.center, - ) - ], - bottom: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - MintYButtonNavigate( - route: const MainSearchLoader(), - text: Text(AppLocalizations.of(context)!.cancel, - style: MintY.heading4), - color: Colors.white, - ), - const SizedBox(width: 16), - MintYButtonNavigate( - route: const SecurityCheckOverview(), - text: Text(AppLocalizations.of(context)!.retry, - style: MintY.heading4White), - color: MintY.currentColor, - ), - ], - ), - )); - } - - // At first set everything to "safe": - List additionalSources = []; - bool yayInstalled = false; - int availableUpdatePackages = 0; - bool homeFolderSecure = true; - bool firewallNotInstalled = false; - bool firewallRunning = true; - bool sshRunning = false; - bool xrdpRunning = false; - bool fail2banRunning = true; - // Then if errors are recongized, then set the specific vars to "unsafe": - for (String line in lines) { - if (line.startsWith("additionalsource:")) { - additionalSources - .add(line.replaceFirst("additionalsource: ", "")); - } - if (line.startsWith("yayinstalled")) { - yayInstalled = true; - } - if (line.startsWith("upgradeablepackage:")) { - availableUpdatePackages++; - } - if (line.startsWith("homefoldernotsecure:")) { - homeFolderSecure = false; - } - if (line.startsWith("firewallinactive")) { - firewallRunning = false; - } - if (line.startsWith("nofirewall")) { - firewallNotInstalled = true; - } - if (line.startsWith("xrdprunning")) { - xrdpRunning = true; - } - if (line.startsWith("sshrunning")) { - sshRunning = true; - } - if (line.startsWith("fail2bannotrunning")) { - fail2banRunning = false; - } - } + if (!snapshot.hasData) { + return MintYLoadingPage(text: l10n.analysingSystemSecurity); + } - return Scaffold( - body: MintYPage( - title: AppLocalizations.of(context)!.securityCheck, - contentElements: [ - AdditionSoftwareSources( - additionalSources: additionalSources, - yayInstalled: yayInstalled), - const SizedBox(height: 16), - UpdateCheck(availableUpdatePackages: availableUpdatePackages), - const SizedBox(height: 16), - HomeFolderSecurityCheck(homeFolderSecure: homeFolderSecure), - const SizedBox(height: 16), - NetworkSecurityCheck( - firewallNotInstalled: firewallNotInstalled, - firewallRunning: firewallRunning, - sshRunning: sshRunning, - xrdpRunning: xrdpRunning, - fail2banRunning: fail2banRunning), - ], - bottom: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - MintYButtonNavigate( - route: const MainSearchLoader(), - text: Text(AppLocalizations.of(context)!.backToSearch, - style: MintY.heading4), - color: Colors.white, - ), - const SizedBox(width: 16), - MintYButtonNavigate( - route: const SecurityCheckOverview(), - text: Text(AppLocalizations.of(context)!.reload, - style: MintY.heading4White), - color: MintY.currentColor, - ), - ], - ), - )); - } else { - return MintYLoadingPage( - text: AppLocalizations.of(context)!.analysingSystemSecurity); + final output = snapshot.data!; + if (!output.contains("#!script ran successfully.")) { + return _rootRequired(context); } + + return ListView( + padding: const EdgeInsets.all(HermesTokens.space4), + children: [ + _readOnlyBanner(context), + const SizedBox(height: HermesTokens.space4), + for (final finding in _buildFindings(context, output)) + SecurityFindingTile(finding: finding), + ], + ); }, ); } -} -class UpdateCheck extends StatelessWidget { - const UpdateCheck({ - Key? key, - required this.availableUpdatePackages, - }) : super(key: key); + Widget _readOnlyBanner(BuildContext context) { + final t = HermesTokens.of(context); + final l10n = AppLocalizations.of(context)!; - final int availableUpdatePackages; + return HermesCard( + spineColor: t.accent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + HermesBadge( + icon: Icons.visibility_outlined, + tone: HermesTone.accent, + text: l10n.readOnly, + ), + const Spacer(), + TextButton.icon( + onPressed: _reload, + icon: const Icon(Icons.refresh, size: 14), + label: Text(l10n.reload), + ), + ], + ), + const SizedBox(height: HermesTokens.space2), + Text( + l10n.securityReadOnlyIntro, + style: TextStyle(color: t.muted, fontSize: 12, height: 1.5), + ), + ], + ), + ); + } - @override - Widget build(BuildContext context) { - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - AppLocalizations.of(context)!.updates, - style: Theme.of(context).textTheme.headlineLarge, + Widget _rootRequired(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Center( + child: Padding( + padding: const EdgeInsets.all(HermesTokens.space4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.securityCheckFailedBecauseNoRoot, + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: HermesTokens.space4), + TextButton.icon( + onPressed: _reload, + icon: const Icon(Icons.refresh, size: 16), + label: Text(l10n.retry), + ), + ], + ), ), - Padding( - padding: const EdgeInsets.all(8.0), - child: availableUpdatePackages == 0 - ? SuccessMessage( - text: AppLocalizations.of(context)!.systemIsUpToDate, - ) - : WarningMessage( - text: - "$availableUpdatePackages ${AppLocalizations.of(context)!.xPackagesShouldBeUpdated}", - fixAction: () { - Linux.updateAllPackages(); - Navigator.of(context).push(MaterialPageRoute( - builder: (context) => RunCommandQueue( - title: AppLocalizations.of(context)!.update, - route: const SecurityCheckOverview()))); - }, - )) - ]); + ); } -} -class HomeFolderSecurityCheck extends StatelessWidget { - const HomeFolderSecurityCheck({ - Key? key, - required this.homeFolderSecure, - }) : super(key: key); + /// Turns the checker's line-prefixed output into prioritized findings. + List _buildFindings(BuildContext context, String output) { + final l10n = AppLocalizations.of(context)!; + final lines = output.split("\n"); - final bool homeFolderSecure; + final additionalSources = []; + bool yayInstalled = false; + int availableUpdatePackages = 0; + bool homeFolderSecure = true; + bool firewallNotInstalled = false; + bool firewallRunning = true; + bool sshRunning = false; + bool xrdpRunning = false; + bool fail2banRunning = true; - @override - Widget build(BuildContext context) { - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - AppLocalizations.of(context)!.homeFolder, - style: Theme.of(context).textTheme.headlineLarge, - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: homeFolderSecure - ? SuccessMessage( - text: AppLocalizations.of(context)!.homeFolderRightsOkay, - ) - : WarningMessage( - text: AppLocalizations.of(context)!.homeFolderRightsNotOkay, - fixAction: () async { - await Linux.fixHomeFolderPermissions(); - Navigator.of(context).push(MaterialPageRoute( - builder: (context) => const SecurityCheckOverview(), - )); - }, - )) - ]); - } -} + for (final line in lines) { + if (line.startsWith("additionalsource:")) { + additionalSources.add(line.replaceFirst("additionalsource: ", "")); + } else if (line.startsWith("yayinstalled")) { + yayInstalled = true; + } else if (line.startsWith("upgradeablepackage:")) { + availableUpdatePackages++; + } else if (line.startsWith("homefoldernotsecure:")) { + homeFolderSecure = false; + } else if (line.startsWith("firewallinactive")) { + firewallRunning = false; + } else if (line.startsWith("nofirewall")) { + firewallNotInstalled = true; + } else if (line.startsWith("xrdprunning")) { + xrdpRunning = true; + } else if (line.startsWith("sshrunning")) { + sshRunning = true; + } else if (line.startsWith("fail2bannotrunning")) { + fail2banRunning = false; + } + } -class NetworkSecurityCheck extends StatelessWidget { - const NetworkSecurityCheck({ - Key? key, - required this.firewallNotInstalled, - required this.firewallRunning, - required this.sshRunning, - required this.xrdpRunning, - required this.fail2banRunning, - }) : super(key: key); + final findings = []; - final bool firewallNotInstalled; - final bool firewallRunning; - final bool sshRunning; - final bool xrdpRunning; - final bool fail2banRunning; + // --- Network --- + if (firewallNotInstalled) { + findings.add(SecurityFinding( + severity: FindingSeverity.high, + title: l10n.noFirewallRecognized, + why: l10n.whyFirewall, + command: "sudo ufw status verbose", + )); + } else if (!firewallRunning) { + findings.add(SecurityFinding( + severity: FindingSeverity.high, + title: l10n.firewallIsInactive, + why: l10n.whyFirewall, + command: "sudo ufw status verbose", + )); + } else { + findings.add(SecurityFinding( + severity: FindingSeverity.ok, + title: l10n.firewallIsRunning, + )); + } - @override - Widget build(BuildContext context) { - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - AppLocalizations.of(context)!.networkSecurity, - style: Theme.of(context).textTheme.headlineLarge, - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - firewallNotInstalled - ? WarningMessage( - text: AppLocalizations.of(context)!.noFirewallRecognized, - fixAction: () { - Linux.installAndConfigureFirewall( - context, const SecurityCheckOverview()); - }, - ) - : firewallRunning - ? SuccessMessage( - text: AppLocalizations.of(context)!.firewallIsRunning) - : WarningMessage( - text: - AppLocalizations.of(context)!.firewallIsInactive, - fixAction: () { - Linux.installAndConfigureFirewall( - context, const SecurityCheckOverview()); - }, - ), - const SizedBox(height: 8), - sshRunning - ? WarningMessage( - text: - AppLocalizations.of(context)!.sshFoundOnYourComputer) - : Container(), - const SizedBox(height: 8), - (sshRunning && !fail2banRunning) - ? WarningMessage( - text: AppLocalizations.of(context)!.noFail2BanFound) - : Container(), - const SizedBox(height: 8), - xrdpRunning - ? WarningMessage( - text: AppLocalizations.of(context)! - .xrdpRunningOnYourComputer) - : Container(), - ], - )) - ]); - } -} + if (xrdpRunning) { + findings.add(SecurityFinding( + severity: FindingSeverity.high, + title: l10n.xrdpRunningOnYourComputer, + why: l10n.whyXrdp, + command: "systemctl status xrdp", + )); + } -class AdditionSoftwareSources extends StatelessWidget { - const AdditionSoftwareSources({ - Key? key, - required this.additionalSources, - required this.yayInstalled, - }) : super(key: key); + if (sshRunning) { + findings.add(SecurityFinding( + severity: + fail2banRunning ? FindingSeverity.info : FindingSeverity.high, + title: l10n.sshFoundOnYourComputer, + why: l10n.whySsh, + command: "sudo ss -tlnp | grep ':22'", + )); + if (!fail2banRunning) { + findings.add(SecurityFinding( + severity: FindingSeverity.high, + title: l10n.noFail2BanFound, + why: l10n.whyFail2ban, + command: "systemctl is-active fail2ban", + )); + } + } - final List additionalSources; - final bool yayInstalled; + // --- Home folder --- + if (!homeFolderSecure) { + findings.add(SecurityFinding( + severity: FindingSeverity.medium, + title: l10n.homeFolderRightsNotOkay, + why: l10n.whyHomeFolder, + command: "ls -ld ~", + )); + } else { + findings.add(SecurityFinding( + severity: FindingSeverity.ok, + title: l10n.homeFolderRightsOkay, + )); + } - @override - Widget build(BuildContext context) { - List additionalSourceTexts = []; - for (var element in additionalSources) { - additionalSourceTexts.add(Text(element)); + // --- Updates --- + if (availableUpdatePackages > 0) { + findings.add(SecurityFinding( + severity: FindingSeverity.medium, + title: "$availableUpdatePackages ${l10n.xPackagesShouldBeUpdated}", + why: l10n.whyUpdates, + command: _updateListCommand(), + )); + } else { + findings.add(SecurityFinding( + severity: FindingSeverity.ok, + title: l10n.systemIsUpToDate, + )); } - // If yay is installed, then we show a warning message instead of the success message. - Widget ourChild = SuccessMessage( - text: AppLocalizations.of(context)!.noAdditionalSoftwareSourcesFound); + // --- Software sources --- + if (additionalSources.isNotEmpty) { + findings.add(SecurityFinding( + severity: FindingSeverity.medium, + title: l10n.additionalSoftwareSourcesDetected, + why: l10n.whySources, + details: additionalSources, + command: _sourcesCommand(), + )); + } else if (!yayInstalled) { + findings.add(SecurityFinding( + severity: FindingSeverity.ok, + title: l10n.noAdditionalSoftwareSourcesFound, + )); + } if (yayInstalled) { - ourChild = WarningMessage( - text: AppLocalizations.of(context)!.yayInstalled, - ); + findings.add(SecurityFinding( + severity: FindingSeverity.medium, + title: l10n.yayInstalled, + why: l10n.whyYay, + command: "pacman -Qi yay", + )); } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context)!.additionalSoftwareSources, - style: Theme.of(context).textTheme.headlineLarge, - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: additionalSources.isNotEmpty - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - WarningMessage( - text: AppLocalizations.of(context)! - .additionalSoftwareSourcesDetected, - fixAction: () { - Linux.openAdditionalSoftwareSourcesSettings(); - }, - ), - const SizedBox( - height: 8, - ), - Container( - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(8)), - color: Colors.grey), - padding: const EdgeInsets.all(8), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: additionalSourceTexts, - ), - ), - ], - ), - ), - ], - ) - // If no software source found we wether show a correct success message or a warning message if yay is installed. - : ourChild) - ], - ); + // Most severe first, so the important lines are never below the fold. + findings.sort((a, b) => a.severity.index.compareTo(b.severity.index)); + return findings; + } + + static String _updateListCommand() { + switch (Linux.currentenvironment.distribution) { + case DISTROS.FEDORA: + return "dnf check-update"; + case DISTROS.OPENSUSE: + return "zypper list-updates"; + case DISTROS.ARCH: + case DISTROS.MANJARO: + case DISTROS.ENDEAVOUR: + return "checkupdates"; + default: + return "apt list --upgradable"; + } + } + + static String _sourcesCommand() { + switch (Linux.currentenvironment.distribution) { + case DISTROS.FEDORA: + return "dnf repolist enabled"; + case DISTROS.OPENSUSE: + return "zypper repos"; + case DISTROS.ARCH: + case DISTROS.MANJARO: + case DISTROS.ENDEAVOUR: + return "grep -H '^\\[' /etc/pacman.conf"; + default: + return "ls -l /etc/apt/sources.list.d/"; + } } } diff --git a/lib/layouts/security_check/security_finding.dart b/lib/layouts/security_check/security_finding.dart new file mode 100644 index 0000000..b3cade8 --- /dev/null +++ b/lib/layouts/security_check/security_finding.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:linux_assistant/l10n/app_localizations.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_badge.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_card.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_copy_command.dart'; + +/// Priority of a finding, following the P0-P3 ladder. +/// +/// P0 is reserved for something actively exploitable; nothing the bundled +/// checker detects reaches that bar on its own, so it exists for completeness +/// rather than being emitted today. +enum FindingSeverity { critical, high, medium, info, ok } + +/// One security observation, rendered read-only. +/// +/// The app inspects and explains; it does not change anything here. Each +/// finding therefore carries the command to look closer or to act, and the +/// user decides whether to run it. +@immutable +class SecurityFinding { + const SecurityFinding({ + required this.severity, + required this.title, + this.why, + this.command, + this.commandCaption, + this.details = const [], + }); + + final FindingSeverity severity; + final String title; + + /// Short justification, so a finding is not just a red line. + final String? why; + + /// A read-only inspection command, or the change the user may choose to make + /// themselves. Never executed by the app. + final String? command; + final String? commandCaption; + + /// Extra lines, e.g. the actual repository entries that were found. + final List details; +} + +/// Renders a [SecurityFinding] as a card. +class SecurityFindingTile extends StatelessWidget { + const SecurityFindingTile({super.key, required this.finding}); + + final SecurityFinding finding; + + @override + Widget build(BuildContext context) { + final t = HermesTokens.of(context); + final l10n = AppLocalizations.of(context)!; + final tone = _toneFor(finding.severity); + final colors = hermesToneColors(t, tone); + + return Padding( + padding: const EdgeInsets.only(bottom: HermesTokens.space3), + child: HermesCard( + spineColor: finding.severity == FindingSeverity.ok ? null : colors.fg, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(_iconFor(finding.severity), size: 16, color: colors.fg), + const SizedBox(width: HermesTokens.space2), + Expanded( + child: Text( + finding.title, + style: TextStyle( + color: t.text, + fontSize: 14, + height: 1.4, + ), + ), + ), + const SizedBox(width: HermesTokens.space2), + HermesBadge( + dense: true, + tone: tone, + text: _severityLabel(context, finding.severity), + ), + ], + ), + if (finding.why != null) ...[ + const SizedBox(height: HermesTokens.space2), + Padding( + padding: const EdgeInsets.only(left: 24), + child: Text( + "${l10n.whyItMatters}: ${finding.why!}", + style: TextStyle( + color: t.muted.withValues(alpha: HermesTokens.opacityStrong), + fontSize: 12, + height: 1.5, + ), + ), + ), + ], + if (finding.details.isNotEmpty) ...[ + const SizedBox(height: HermesTokens.space2), + Container( + width: double.infinity, + padding: const EdgeInsets.all(HermesTokens.space2), + decoration: BoxDecoration( + color: t.surfaceSubtle, + borderRadius: BorderRadius.circular(HermesTokens.radiusSm), + border: Border.all( + color: t.borderSubtle, width: HermesTokens.borderWidth), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final detail in finding.details) + Text( + detail, + style: TextStyle( + color: t.muted, + fontFamily: HermesTokens.fontMono, + fontSize: 11.5, + ), + ), + ], + ), + ), + ], + if (finding.command != null) ...[ + const SizedBox(height: HermesTokens.space3), + HermesCopyCommand( + command: finding.command!, + caption: finding.commandCaption ?? l10n.checkItYourself, + ), + ], + ], + ), + ), + ); + } + + static HermesTone _toneFor(FindingSeverity severity) { + switch (severity) { + case FindingSeverity.critical: + case FindingSeverity.high: + return HermesTone.error; + case FindingSeverity.medium: + return HermesTone.warning; + case FindingSeverity.info: + return HermesTone.info; + case FindingSeverity.ok: + return HermesTone.success; + } + } + + static IconData _iconFor(FindingSeverity severity) { + switch (severity) { + case FindingSeverity.critical: + case FindingSeverity.high: + return Icons.error_outline; + case FindingSeverity.medium: + return Icons.warning_amber; + case FindingSeverity.info: + return Icons.info_outline; + case FindingSeverity.ok: + return Icons.check_circle_outline; + } + } + + static String _severityLabel(BuildContext context, FindingSeverity severity) { + final l10n = AppLocalizations.of(context)!; + switch (severity) { + case FindingSeverity.critical: + return "P0 ${l10n.severityCritical}"; + case FindingSeverity.high: + return "P1 ${l10n.severityHigh}"; + case FindingSeverity.medium: + return "P2 ${l10n.severityMedium}"; + case FindingSeverity.info: + return "P3 ${l10n.severityInfo}"; + case FindingSeverity.ok: + return "OK"; + } + } +} From dd8a241997776d23722dd09a3b86c3ae5dec889f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 13:00:14 +0000 Subject: [PATCH 5/6] Add translations, tests and feature matrix rows Replaces test/widget_test.dart, which held the unmodified Flutter counter template: it asserted on a counter and an add button this app has never had, so it could not pass, and CI never ran it. The 50 tests cover what the redesign relies on: - Every text-bearing color pair clears WCAG AA in both palettes, so a future palette edit cannot quietly undo the contrast fixes, including when a distribution accent is substituted. - The df, ps, uptime and free parsers, against captured output. - Reference counting in the stats poller. - The new widgets, including that a security finding exposes no control other than copy-to-clipboard. Writing them caught a real bug: HermesCard drew the accent spine as a left border of a different color while also setting a border radius, which Flutter rejects at paint time. Since most dashboard and security cards use a spine, that would have crashed the surfaces it was meant to decorate. The spine is now painted as an overlay. New strings are translated in English, German and Italian; five keys that duplicated existing ones were dropped in favour of the originals. The generated localizations are committed because the project keeps them in lib/l10n rather than generating them at build time. pubspec.lock picks up SDK-pinned transitive test packages from running pub get with Flutter 3.44. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HioAo2j6ufA9GsyKBWF69u --- features.csv | 4 + lib/l10n/app_de.arb | 352 ++++++++++++++++++++++++++-- lib/l10n/app_en.arb | 358 +++++++++++++++++++++++++++-- lib/l10n/app_it.arb | 356 ++++++++++++++++++++++++++-- lib/l10n/app_localizations.dart | 306 ++++++++++++++++++++++++ lib/l10n/app_localizations_de.dart | 170 ++++++++++++++ lib/l10n/app_localizations_en.dart | 169 ++++++++++++++ lib/l10n/app_localizations_fi.dart | 169 ++++++++++++++ lib/l10n/app_localizations_it.dart | 170 ++++++++++++++ pubspec.lock | 38 +-- test/hermes_tokens_test.dart | 178 ++++++++++++++ test/hermes_widgets_test.dart | 270 ++++++++++++++++++++++ test/system_parsers_test.dart | 197 ++++++++++++++++ test/widget_test.dart | 99 ++++++-- 14 files changed, 2734 insertions(+), 102 deletions(-) create mode 100644 test/hermes_tokens_test.dart create mode 100644 test/hermes_widgets_test.dart create mode 100644 test/system_parsers_test.dart diff --git a/features.csv b/features.csv index 98e5a92..d9ff079 100644 --- a/features.csv +++ b/features.csv @@ -36,3 +36,7 @@ setupSnapAndInstallSnapStore,?,yes,yes,yes,yes,yes,yes,yes,yes,no,yes,yes,?,?,-, Commands in Searchbar,?,-,-,-,-,-,-,-,-,-,-,-,yes,yes,yes,yes,yes,yes, AutomaticRepairOfPackageManager,?,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,no,no,no,-,-,-,-, openSoftwareCenter,?,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes, +Hub dashboard,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes, +Live system tiles (CPU/RAM/disk),yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes, +Theme switching without restart,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes, +Read-only security findings,?,yes,yes,yes,yes,yes,yes,yes,yes,yes *,yes *,yes,yes,yes,-,-,-,-,"*) checker script does not report an update count on these distributions" diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e95d307..f03185e 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -128,7 +128,7 @@ "openLinuxAssistantFasterDescription": "Mit der Tastenkombination {modifier} + könntest Du direkt Deinen Assistenten schneller öffnen.\nUm mit Deinem Rechner produktiver zu arbeiten, wird dieser Schritt wärmstens empfohlen.", "@openLinuxAssistantFasterDescription": { "placeholders": { - "modifier" : { + "modifier": { "type": "Object" } }, @@ -153,7 +153,7 @@ "youCanOpenLinuxAssistantWithHotkey": "Du kannst den Linux-Assistant immer mit der Tastenkombination {modifier} + öffnen. Wenn dies noch nicht funktioniert, rufe in der Suche 'Tastatur Kurzbefehl' auf.", "@youCanOpenLinuxAssistantWithHotkey": { "placeholders": { - "modifier" : { + "modifier": { "type": "Object" } }, @@ -169,10 +169,10 @@ "uptimeWarning": "Dein Linux-System wurde schon lange nicht mehr neu gestartet. Um potentielle Fehler zu vermeiden werden regelmäßige Neustarts empfohlen. Der letzte Neustart war vor {time} {unit}.", "@uptimeWarning": { "placeholders": { - "time" : { + "time": { "type": "Object" }, - "unit" : { + "unit": { "type": "Object" } }, @@ -182,10 +182,10 @@ "uptimePass": "Dein Linux-System läuft erst seit {time} {unit}. Das ist gut!", "@uptimePass": { "placeholders": { - "time" : { + "time": { "type": "Object" }, - "unit" : { + "unit": { "type": "Object" } }, @@ -198,10 +198,10 @@ "processesWithZombiesMessage": "Aktuell laufen {processes} Prozesse. Von diesen wurden {zombies} Zombies gefunden.", "@processesWithZombiesMessage": { "placeholders": { - "processes" : { + "processes": { "type": "Object" }, - "zombies" : { + "zombies": { "type": "Object" } }, @@ -211,7 +211,7 @@ "removableDevicesWarning": "Es wurden {removableDevices} entfernbare Speicher-Geräte/(Partitionen) gefunden. Wenn Du diese aktuell nicht benötigst, wird aufgrund Sicherheitsgründen und der Gesundheit dieser Geräte empfohlen, diese zu entfernen.", "@removableDevicesWarning": { "placeholders": { - "removableDevices" : { + "removableDevices": { "type": "Object" } }, @@ -232,7 +232,7 @@ "applyUpdatesSinceReleaseDescription": "Seit der Veröffentlichung der aktuellen {distro}-Version sind unter Umständen Aktualisierungen verfügbar. Wende diese Aktualisierungen an.", "@applyUpdatesSinceReleaseDescription": { "placeholders": { - "distro" : { + "distro": { "type": "Object" } }, @@ -246,7 +246,7 @@ "setUpLinuxAssistantKeyboardShortcut": "Richte die Tastenkombination {modifier} + ein, um Linux-Assistant von überall aus schnell zu öffnen.", "@setUpLinuxAssistantKeyboardShortcut": { "placeholders": { - "modifier" : { + "modifier": { "type": "Object" } }, @@ -264,7 +264,7 @@ "installX": "Installiere {app}", "@installX": { "placeholders": { - "app" : { + "app": { "type": "Object" } }, @@ -274,7 +274,7 @@ "installingXDescription": "Installiere {app}...\nSie müssen {app} danach separat öffnen.", "@installingXDescription": { "placeholders": { - "app" : { + "app": { "type": "Object" } }, @@ -320,7 +320,7 @@ "activate": "Aktivieren", "active": "Aktiv", "installPowerProfilesDeamonDescription": "Um den Leistungsmodus zu ändern, wird power-profiles-deamon benötigt. Möchtest Du diesen installieren?", - "folderRecursionDepth" : "Ordner-Rekursions-Tiefe", + "folderRecursionDepth": "Ordner-Rekursions-Tiefe", "shutdownAfterwards": "Rechner danach herunterfahren", "versionOfLinuxAssistant": "Version von Linux-Assistant", "urls": "URL", @@ -330,7 +330,7 @@ "uninstallingXDescription": "Deinstalliere {app}...", "@uninstallingXDescription": { "placeholders": { - "app" : { + "app": { "type": "Object" } }, @@ -340,17 +340,17 @@ "uninstallApp": "Deinstalliere {app}", "@uninstallApp": { "placeholders": { - "app" : { + "app": { "type": "Object" } }, "description": "", "type": "text" - }, + }, "uninstallAppWarning": "Möchtest Du wirklich {app} deinstallieren?\nDas System könnte danach nicht mehr richtig funktionieren.\nBitte deinstalliere nur Programme, die Du selber installiert hast.", "@uninstallAppWarning": { "placeholders": { - "app" : { + "app": { "type": "Object" } }, @@ -374,7 +374,7 @@ "back": "Zurück", "@cleanX": { "placeholders": { - "app" : { + "app": { "type": "Object" } }, @@ -926,5 +926,319 @@ "placeholders": {}, "description": "", "type": "text" + }, + "dashboard": "Dashboard", + "@dashboard": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hubStorage": "Speicher", + "@hubStorage": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hubSoftware": "Software & Updates", + "@hubSoftware": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hubSystem": "System", + "@hubSystem": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hubSearch": "Suche", + "@hubSearch": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "quickActions": "Schnellaktionen", + "@quickActions": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hubSearchPlaceholder": "Apps, Dateien, Einstellungen und Aktionen durchsuchen...", + "@hubSearchPlaceholder": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "copyCommand": "Befehl kopieren", + "@copyCommand": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "copied": "Kopiert", + "@copied": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "readOnly": "Nur Lesen", + "@readOnly": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "readOnlyNotice": "Diese Ansicht liest dein System nur aus. Sie ändert nichts.", + "@readOnlyNotice": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyItMatters": "Warum das relevant ist", + "@whyItMatters": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "checkItYourself": "Selbst prüfen oder ändern", + "@checkItYourself": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "severityCritical": "Kritisch", + "@severityCritical": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "severityHigh": "Hoch", + "@severityHigh": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "severityMedium": "Mittel", + "@severityMedium": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "severityInfo": "Info", + "@severityInfo": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "uptimeLabel": "Laufzeit", + "@uptimeLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "processesLabel": "Prozesse", + "@processesLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "zombiesLabel": "Zombies", + "@zombiesLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "swapLabel": "Swap", + "@swapLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "themeSystem": "System folgen", + "@themeSystem": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "themeLight": "Hell", + "@themeLight": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "themeDark": "Dunkel", + "@themeDark": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "switchTheme": "Design wechseln", + "@switchTheme": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "useDistroColors": "Farben der Distribution verwenden", + "@useDistroColors": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "useDistroColorsDescription": "Ersetzt die Standard-Akzentfarbe durch eine zur Distribution passende Farbe.", + "@useDistroColorsDescription": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "collectingData": "Systemdaten werden erfasst...", + "@collectingData": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "statsUnavailable": "Systemdaten konnten nicht gelesen werden. Es werden die letzten bekannten Werte angezeigt.", + "@statsUnavailable": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "kernelLabel": "Kernel", + "@kernelLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "desktopLabel": "Desktop", + "@desktopLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hostLabel": "Host", + "@hostLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "collapseSidebar": "Seitenleiste einklappen", + "@collapseSidebar": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "expandSidebar": "Seitenleiste ausklappen", + "@expandSidebar": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "noQuickActions": "Noch keine Schnellaktionen verfügbar.", + "@noQuickActions": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "topProcesses": "Top-Prozesse", + "@topProcesses": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "mountedDisks": "Eingebundene Datenträger", + "@mountedDisks": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "securityReadOnlyIntro": "Linux Assistant prüft diese Einstellungen, ohne sie zu verändern. Zu jedem Befund gibt es den Befehl, um selbst nachzusehen oder etwas zu ändern.", + "@securityReadOnlyIntro": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "uptimeUnitShortMinutes": "Min.", + "@uptimeUnitShortMinutes": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "uptimeUnitShortHours": "Std.", + "@uptimeUnitShortHours": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "uptimeUnitShortDays": "Tg.", + "@uptimeUnitShortDays": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "ofTotal": "von {total}", + "@ofTotal": { + "placeholders": { + "total": { + "type": "String" + } + }, + "description": "", + "type": "text" + }, + "updatesAvailableCount": "{count} Updates verfügbar", + "@updatesAvailableCount": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "", + "type": "text" + }, + "whyFirewall": "Eine Firewall blockiert eingehende Verbindungen, die du nie zulassen wolltest.", + "@whyFirewall": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whySsh": "Ein SSH-Server nimmt Anmeldungen über das Netzwerk an. Läuft er ungewollt, vergrößert das die Angriffsfläche.", + "@whySsh": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyFail2ban": "Ohne fail2ban werden wiederholte fehlgeschlagene SSH-Anmeldungen weder gebremst noch blockiert.", + "@whyFail2ban": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyXrdp": "Ein Remote-Desktop-Server gibt deine gesamte Sitzung über das Netzwerk frei.", + "@whyXrdp": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyHomeFolder": "Ein für alle lesbares Home-Verzeichnis erlaubt jedem lokalen Konto, deine persönlichen Dateien zu lesen.", + "@whyHomeFolder": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyUpdates": "Ausstehende Updates enthalten häufig Sicherheitskorrekturen.", + "@whyUpdates": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whySources": "Fremdquellen können Pakete liefern, die Systempakete ersetzen.", + "@whySources": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyYay": "AUR-Helfer installieren Community-Pakete, die niemand für dich geprüft hat.", + "@whyYay": { + "placeholders": {}, + "description": "", + "type": "text" } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c69893d..c5c29e9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -128,7 +128,7 @@ "openLinuxAssistantFasterDescription": "You could open Linux Assistant with the keys {modifier} + .\nThis is higly recommended to maximize your productivity under linux.", "@openLinuxAssistantFasterDescription": { "placeholders": { - "modifier" : {} + "modifier": {} }, "description": "", "type": "text" @@ -151,7 +151,7 @@ "youCanOpenLinuxAssistantWithHotkey": "You can always open Linux Assistant with the key combination {modifier} + . If this does not work yet, call 'keyboard shortcut' in the search.", "@youCanOpenLinuxAssistantWithHotkey": { "placeholders": { - "modifier" : {} + "modifier": {} }, "description": "", "type": "text" @@ -165,8 +165,8 @@ "uptimeWarning": "Your Linux system has not been rebooted for a long time. To avoid potential errors, regular reboots are recommended. The last reboot was {time} {unit} ago.", "@uptimeWarning": { "placeholders": { - "time" : {}, - "unit" : {} + "time": {}, + "unit": {} }, "description": "", "type": "text" @@ -174,8 +174,8 @@ "uptimePass": "Your Linux system has only been running for {time} {unit}. That's good!", "@uptimePass": { "placeholders": { - "time" : {}, - "unit" : {} + "time": {}, + "unit": {} }, "description": "", "type": "text" @@ -186,8 +186,8 @@ "processesWithZombiesMessage": "Currently {processes} processes are running. Of these {zombies} zombies where found.", "@processesWithZombiesMessage": { "placeholders": { - "processes" : {}, - "zombies" : {} + "processes": {}, + "zombies": {} }, "description": "", "type": "text" @@ -195,7 +195,7 @@ "removableDevicesWarning": "{removableDevices} removable storage devices/(partitions) were found. If you do not currently need them, it is recommended to remove them due to security reasons and the health of these devices.", "@removableDevicesWarning": { "placeholders": { - "removableDevices" : {} + "removableDevices": {} }, "description": "", "type": "text" @@ -214,7 +214,7 @@ "applyUpdatesSinceReleaseDescription": "Since the release of the current {distro} version, updates may be available. Apply these updates.", "@applyUpdatesSinceReleaseDescription": { "placeholders": { - "distro" : {} + "distro": {} }, "description": "", "type": "text" @@ -226,7 +226,7 @@ "setUpLinuxAssistantKeyboardShortcut": "Set up the keyboard combination {modifier} + to quickly open Linux Assistant from anywhere.", "@setUpLinuxAssistantKeyboardShortcut": { "placeholders": { - "modifier" : {} + "modifier": {} }, "description": "", "type": "text" @@ -242,7 +242,7 @@ "installX": "Install {app}", "@installX": { "placeholders": { - "app" : {} + "app": {} }, "description": "", "type": "text" @@ -250,7 +250,7 @@ "installingXDescription": "Installing {app}...\nYou have to open {app} after separatly.", "@installingXDescription": { "placeholders": { - "app" : {} + "app": {} }, "description": "", "type": "text" @@ -277,7 +277,7 @@ "secondaryColorSetting": "Secondary color (restart required)", "colorfulBackground": "Colorful background", "theFollowingCommandsWillBeExecuted": "The following commands will be executed", - "command" : "Command", + "command": "Command", "showCommands": "Show commands", "hideCommands": "Hide commands", "description": "Description", @@ -285,7 +285,7 @@ "no": "No", "selfLearningSearch": "Self-learning search", "updateSystemDescription": "Update your Linux machine and all applications (updates).\nThis process may take a few minutes.", - "featureOverview" : "Feature overview", + "featureOverview": "Feature overview", "powerMode": "Power mode", "powerModeDescription": "Switch between power saving and performance mode.", "powerSaver": "power saving mode", @@ -294,7 +294,7 @@ "activate": "Activate", "active": "Active", "installPowerProfilesDeamonDescription": "To change the power mode you need power-profiles-deamon. Do you want to install this?", - "folderRecursionDepth" : "Folder recursion depth", + "folderRecursionDepth": "Folder recursion depth", "shutdownAfterwards": "Shutdown Linux afterwards", "versionOfLinuxAssistant": "Version of Linux Assistant", "urls": "URL", @@ -304,7 +304,7 @@ "uninstallingXDescription": "Uninstalling {app}...", "@uninstallingXDescription": { "placeholders": { - "app" : {} + "app": {} }, "description": "", "type": "text" @@ -312,7 +312,7 @@ "uninstallApp": "Uninstall {app}", "@uninstallApp": { "placeholders": { - "app" : {} + "app": {} }, "description": "", "type": "text" @@ -320,8 +320,8 @@ "uninstallAppWarning": "Do you really want to uninstall {app}?\nThe system might not work properly afterwards.\nPlease uninstall only programs you installed yourself.", "@uninstallAppWarning": { "placeholders": { - "app" : {} - }, + "app": {} + }, "description": "", "type": "text" }, @@ -336,7 +336,7 @@ "cleanX": "Clean {app}", "@cleanX": { "placeholders": { - "app" : {} + "app": {} }, "description": "", "type": "text" @@ -892,5 +892,319 @@ "placeholders": {}, "description": "", "type": "text" + }, + "dashboard": "Dashboard", + "@dashboard": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hubStorage": "Storage", + "@hubStorage": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hubSoftware": "Software & updates", + "@hubSoftware": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hubSystem": "System", + "@hubSystem": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hubSearch": "Search", + "@hubSearch": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "quickActions": "Quick actions", + "@quickActions": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hubSearchPlaceholder": "Search apps, files, settings and actions...", + "@hubSearchPlaceholder": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "copyCommand": "Copy command", + "@copyCommand": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "copied": "Copied", + "@copied": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "readOnly": "Read-only", + "@readOnly": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "readOnlyNotice": "This view only reads your system. It never changes anything.", + "@readOnlyNotice": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyItMatters": "Why this matters", + "@whyItMatters": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "checkItYourself": "Check or change it yourself", + "@checkItYourself": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "severityCritical": "Critical", + "@severityCritical": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "severityHigh": "High", + "@severityHigh": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "severityMedium": "Medium", + "@severityMedium": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "severityInfo": "Info", + "@severityInfo": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "uptimeLabel": "Uptime", + "@uptimeLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "processesLabel": "Processes", + "@processesLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "zombiesLabel": "Zombies", + "@zombiesLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "swapLabel": "Swap", + "@swapLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "themeSystem": "Follow system", + "@themeSystem": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "themeLight": "Light", + "@themeLight": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "themeDark": "Dark", + "@themeDark": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "switchTheme": "Switch theme", + "@switchTheme": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "useDistroColors": "Use distribution colors", + "@useDistroColors": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "useDistroColorsDescription": "Replaces the default accent with a color matching your distribution.", + "@useDistroColorsDescription": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "collectingData": "Collecting system data...", + "@collectingData": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "statsUnavailable": "System data could not be read. Showing the last known values.", + "@statsUnavailable": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "kernelLabel": "Kernel", + "@kernelLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "desktopLabel": "Desktop", + "@desktopLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hostLabel": "Host", + "@hostLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "collapseSidebar": "Collapse sidebar", + "@collapseSidebar": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "expandSidebar": "Expand sidebar", + "@expandSidebar": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "noQuickActions": "No quick actions available yet.", + "@noQuickActions": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "topProcesses": "Top processes", + "@topProcesses": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "mountedDisks": "Mounted disks", + "@mountedDisks": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "securityReadOnlyIntro": "Linux Assistant inspects these settings without changing them. Every finding comes with the command to check or change it yourself.", + "@securityReadOnlyIntro": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "uptimeUnitShortMinutes": "min", + "@uptimeUnitShortMinutes": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "uptimeUnitShortHours": "h", + "@uptimeUnitShortHours": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "uptimeUnitShortDays": "d", + "@uptimeUnitShortDays": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "ofTotal": "of {total}", + "@ofTotal": { + "placeholders": { + "total": { + "type": "String" + } + }, + "description": "", + "type": "text" + }, + "updatesAvailableCount": "{count} updates available", + "@updatesAvailableCount": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "", + "type": "text" + }, + "whyFirewall": "A firewall blocks incoming connections you never intended to accept.", + "@whyFirewall": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whySsh": "An SSH server accepts logins over the network. If it runs unintentionally, it widens your attack surface.", + "@whySsh": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyFail2ban": "Without fail2ban, repeated failed SSH logins are never throttled or blocked.", + "@whyFail2ban": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyXrdp": "A remote desktop server exposes your entire session over the network.", + "@whyXrdp": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyHomeFolder": "A world-readable home directory lets any local account read your personal files.", + "@whyHomeFolder": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyUpdates": "Pending updates frequently contain security fixes.", + "@whyUpdates": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whySources": "Third-party repositories can ship packages that replace system ones.", + "@whySources": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyYay": "AUR helpers install community packages that nobody reviewed for you.", + "@whyYay": { + "placeholders": {}, + "description": "", + "type": "text" } -} \ No newline at end of file +} diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 7130dcf..a1e62bc 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -128,7 +128,7 @@ "openLinuxAssistantFasterDescription": "Puoi aprire Linux Assistant con i tasti {modifier} + . Se ancora non funziona, chiama 'scorciatoia da tastiera' nella ricerca.", "@youCanOpenLinuxAssistantWithHotkey": { "placeholders": { - "modifier" : { + "modifier": { "type": "Object" } }, @@ -169,10 +169,10 @@ "uptimeWarning": "Il tuo sistema Linux non è stato riavviato da molto tempo. Per evitare potenziali errori, si consigliano riavvii regolari. L'ultimo riavvio è avvenuto {time} {unit} fa.", "@uptimeWarning": { "placeholders": { - "time" : { + "time": { "type": "Object" }, - "unit" : { + "unit": { "type": "Object" } }, @@ -182,10 +182,10 @@ "uptimePass": "Il tuo sistema Linux è in esecuzione solo da {time} {unit}. Questo è buono!", "@uptimePass": { "placeholders": { - "time" : { + "time": { "type": "Object" }, - "unit" : { + "unit": { "type": "Object" } }, @@ -198,10 +198,10 @@ "processesWithZombiesMessage": "Attualmente sono in esecuzione {processes} processi. Di questi {zombies} sono stati trovati zombi.", "@processesWithZombiesMessage": { "placeholders": { - "processes" : { + "processes": { "type": "Object" }, - "zombies" : { + "zombies": { "type": "Object" } }, @@ -211,7 +211,7 @@ "removableDevicesWarning": "{removableDevices} sono stati trovati dispositivi/(partizioni) di archiviazione rimovibili. Se al momento non ne hai bisogno, ti consigliamo di rimuoverli per motivi di sicurezza e di salute di questi dispositivi.", "@removableDevicesWarning": { "placeholders": { - "removableDevices" : { + "removableDevices": { "type": "Object" } }, @@ -232,7 +232,7 @@ "applyUpdatesSinceReleaseDescription": "Dal rilascio dell'attuale versione di {distro}, potrebbero essere disponibili aggiornamenti. Applica questi aggiornamenti.", "@applyUpdatesSinceReleaseDescription": { "placeholders": { - "distro" : { + "distro": { "type": "Object" } }, @@ -246,7 +246,7 @@ "setUpLinuxAssistantKeyboardShortcut": "Imposta la combinazione di tastiera {modifier} + per aprire rapidamente Linux Assistant da qualsiasi luogo.", "@setUpLinuxAssistantKeyboardShortcut": { "placeholders": { - "modifier" : { + "modifier": { "type": "Object" } }, @@ -264,7 +264,7 @@ "installX": "Installa {app}", "@installX": { "placeholders": { - "app" : { + "app": { "type": "Object" } }, @@ -274,7 +274,7 @@ "installingXDescription": "Installazione di {app}...\nDevi aprire {app} successivamente e separatamente.", "@installingXDescription": { "placeholders": { - "app" : { + "app": { "type": "Object" } }, @@ -303,7 +303,7 @@ "secondaryColorSetting": "Colore secondario (riavvio richiesto)", "colorfulBackground": "Sfondo colorato", "theFollowingCommandsWillBeExecuted": "Verranno eseguiti i seguenti comandi", - "command" : "Comando", + "command": "Comando", "showCommands": "Mostra comandi", "hideCommands": "Nascondi comandi", "description": "Descrizione", @@ -311,7 +311,7 @@ "no": "No", "selfLearningSearch": "Ricerca in autoapprendimento", "updateSystemDescription": "Aggiorna la tua macchina Linux e tutte le applicazioni (aggiornamenti).\nQuesto processo potrebbe richiedere alcuni minuti.", - "featureOverview" : "Panoramica delle funzionalità", + "featureOverview": "Panoramica delle funzionalità", "powerMode": "Modalità di alimentazione", "powerModeDescription": "Passa dalla modalità di risparmio energetico a quella di prestazione.", "powerSaver": "modalità di risparmio energetico", @@ -320,7 +320,7 @@ "activate": "Attivare", "active": "Attiva", "installPowerProfilesDeamonDescription": "Per modificare la modalità di risparmio energetico è necessario power-profiles-deamon. Vuoi installarlo?", - "folderRecursionDepth" : "Profondità di ricorsione della cartella", + "folderRecursionDepth": "Profondità di ricorsione della cartella", "shutdownAfterwards": "Successivamente spegni Linux", "versionOfLinuxAssistant": "Versione di Linux Assistant", "urls": "URL", @@ -330,7 +330,7 @@ "uninstallingXDescription": "Disinstallazione di {app}...", "@uninstallingXDescription": { "placeholders": { - "app" : { + "app": { "type": "Object" } }, @@ -340,7 +340,7 @@ "uninstallApp": "Uninstall {app}", "@uninstallApp": { "placeholders": { - "app" : { + "app": { "type": "Object" } }, @@ -350,10 +350,10 @@ "uninstallAppWarning": "Vuoi davvero disinstallare {app}?\nIl sistema potrebbe non funzionare correttamente in seguito.\nDisinstalla solo i programmi che hai installato tu stesso.", "@uninstallAppWarning": { "placeholders": { - "app" : { + "app": { "type": "Object" } - }, + }, "description": "", "type": "text" }, @@ -368,7 +368,7 @@ "cleanX": "Pulisci {app}", "@cleanX": { "placeholders": { - "app" : { + "app": { "type": "Object" } }, @@ -906,5 +906,319 @@ "placeholders": {}, "description": "", "type": "text" + }, + "dashboard": "Dashboard", + "@dashboard": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hubStorage": "Archiviazione", + "@hubStorage": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hubSoftware": "Software e aggiornamenti", + "@hubSoftware": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hubSystem": "Sistema", + "@hubSystem": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hubSearch": "Ricerca", + "@hubSearch": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "quickActions": "Azioni rapide", + "@quickActions": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hubSearchPlaceholder": "Cerca app, file, impostazioni e azioni...", + "@hubSearchPlaceholder": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "copyCommand": "Copia comando", + "@copyCommand": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "copied": "Copiato", + "@copied": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "readOnly": "Sola lettura", + "@readOnly": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "readOnlyNotice": "Questa vista legge solo il sistema. Non modifica nulla.", + "@readOnlyNotice": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyItMatters": "Perché è importante", + "@whyItMatters": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "checkItYourself": "Verifica o modifica tu stesso", + "@checkItYourself": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "severityCritical": "Critico", + "@severityCritical": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "severityHigh": "Alto", + "@severityHigh": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "severityMedium": "Medio", + "@severityMedium": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "severityInfo": "Info", + "@severityInfo": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "uptimeLabel": "Tempo di attività", + "@uptimeLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "processesLabel": "Processi", + "@processesLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "zombiesLabel": "Zombie", + "@zombiesLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "swapLabel": "Swap", + "@swapLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "themeSystem": "Segui il sistema", + "@themeSystem": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "themeLight": "Chiaro", + "@themeLight": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "themeDark": "Scuro", + "@themeDark": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "switchTheme": "Cambia tema", + "@switchTheme": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "useDistroColors": "Usa i colori della distribuzione", + "@useDistroColors": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "useDistroColorsDescription": "Sostituisce il colore principale con uno adatto alla tua distribuzione.", + "@useDistroColorsDescription": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "collectingData": "Raccolta dei dati di sistema...", + "@collectingData": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "statsUnavailable": "Impossibile leggere i dati di sistema. Vengono mostrati gli ultimi valori noti.", + "@statsUnavailable": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "kernelLabel": "Kernel", + "@kernelLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "desktopLabel": "Desktop", + "@desktopLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "hostLabel": "Host", + "@hostLabel": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "collapseSidebar": "Comprimi barra laterale", + "@collapseSidebar": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "expandSidebar": "Espandi barra laterale", + "@expandSidebar": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "noQuickActions": "Nessuna azione rapida disponibile.", + "@noQuickActions": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "topProcesses": "Processi principali", + "@topProcesses": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "mountedDisks": "Dischi montati", + "@mountedDisks": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "securityReadOnlyIntro": "Linux Assistant controlla queste impostazioni senza modificarle. Ogni risultato include il comando per verificarlo o modificarlo autonomamente.", + "@securityReadOnlyIntro": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "uptimeUnitShortMinutes": "min", + "@uptimeUnitShortMinutes": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "uptimeUnitShortHours": "h", + "@uptimeUnitShortHours": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "uptimeUnitShortDays": "g", + "@uptimeUnitShortDays": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "ofTotal": "di {total}", + "@ofTotal": { + "placeholders": { + "total": { + "type": "String" + } + }, + "description": "", + "type": "text" + }, + "updatesAvailableCount": "{count} aggiornamenti disponibili", + "@updatesAvailableCount": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "", + "type": "text" + }, + "whyFirewall": "Un firewall blocca le connessioni in entrata che non intendevi accettare.", + "@whyFirewall": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whySsh": "Un server SSH accetta accessi dalla rete. Se è attivo involontariamente, amplia la superficie di attacco.", + "@whySsh": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyFail2ban": "Senza fail2ban, i tentativi di accesso SSH falliti non vengono limitati né bloccati.", + "@whyFail2ban": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyXrdp": "Un server desktop remoto espone l'intera sessione sulla rete.", + "@whyXrdp": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyHomeFolder": "Una cartella home leggibile da tutti consente a qualsiasi account locale di leggere i tuoi file personali.", + "@whyHomeFolder": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyUpdates": "Gli aggiornamenti in sospeso contengono spesso correzioni di sicurezza.", + "@whyUpdates": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whySources": "I repository di terze parti possono fornire pacchetti che sostituiscono quelli di sistema.", + "@whySources": { + "placeholders": {}, + "description": "", + "type": "text" + }, + "whyYay": "Gli helper AUR installano pacchetti della community che nessuno ha verificato per te.", + "@whyYay": { + "placeholders": {}, + "description": "", + "type": "text" } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5680e0f..aaf6430 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1757,6 +1757,312 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Show package manager actions'** String get showPackageManagerActions; + + /// + /// + /// In en, this message translates to: + /// **'Dashboard'** + String get dashboard; + + /// + /// + /// In en, this message translates to: + /// **'Storage'** + String get hubStorage; + + /// + /// + /// In en, this message translates to: + /// **'Software & updates'** + String get hubSoftware; + + /// + /// + /// In en, this message translates to: + /// **'System'** + String get hubSystem; + + /// + /// + /// In en, this message translates to: + /// **'Search'** + String get hubSearch; + + /// + /// + /// In en, this message translates to: + /// **'Quick actions'** + String get quickActions; + + /// + /// + /// In en, this message translates to: + /// **'Search apps, files, settings and actions...'** + String get hubSearchPlaceholder; + + /// + /// + /// In en, this message translates to: + /// **'Copy command'** + String get copyCommand; + + /// + /// + /// In en, this message translates to: + /// **'Copied'** + String get copied; + + /// + /// + /// In en, this message translates to: + /// **'Read-only'** + String get readOnly; + + /// + /// + /// In en, this message translates to: + /// **'This view only reads your system. It never changes anything.'** + String get readOnlyNotice; + + /// + /// + /// In en, this message translates to: + /// **'Why this matters'** + String get whyItMatters; + + /// + /// + /// In en, this message translates to: + /// **'Check or change it yourself'** + String get checkItYourself; + + /// + /// + /// In en, this message translates to: + /// **'Critical'** + String get severityCritical; + + /// + /// + /// In en, this message translates to: + /// **'High'** + String get severityHigh; + + /// + /// + /// In en, this message translates to: + /// **'Medium'** + String get severityMedium; + + /// + /// + /// In en, this message translates to: + /// **'Info'** + String get severityInfo; + + /// + /// + /// In en, this message translates to: + /// **'Uptime'** + String get uptimeLabel; + + /// + /// + /// In en, this message translates to: + /// **'Processes'** + String get processesLabel; + + /// + /// + /// In en, this message translates to: + /// **'Zombies'** + String get zombiesLabel; + + /// + /// + /// In en, this message translates to: + /// **'Swap'** + String get swapLabel; + + /// + /// + /// In en, this message translates to: + /// **'Follow system'** + String get themeSystem; + + /// + /// + /// In en, this message translates to: + /// **'Light'** + String get themeLight; + + /// + /// + /// In en, this message translates to: + /// **'Dark'** + String get themeDark; + + /// + /// + /// In en, this message translates to: + /// **'Switch theme'** + String get switchTheme; + + /// + /// + /// In en, this message translates to: + /// **'Use distribution colors'** + String get useDistroColors; + + /// + /// + /// In en, this message translates to: + /// **'Replaces the default accent with a color matching your distribution.'** + String get useDistroColorsDescription; + + /// + /// + /// In en, this message translates to: + /// **'Collecting system data...'** + String get collectingData; + + /// + /// + /// In en, this message translates to: + /// **'System data could not be read. Showing the last known values.'** + String get statsUnavailable; + + /// + /// + /// In en, this message translates to: + /// **'Kernel'** + String get kernelLabel; + + /// + /// + /// In en, this message translates to: + /// **'Desktop'** + String get desktopLabel; + + /// + /// + /// In en, this message translates to: + /// **'Host'** + String get hostLabel; + + /// + /// + /// In en, this message translates to: + /// **'Collapse sidebar'** + String get collapseSidebar; + + /// + /// + /// In en, this message translates to: + /// **'Expand sidebar'** + String get expandSidebar; + + /// + /// + /// In en, this message translates to: + /// **'No quick actions available yet.'** + String get noQuickActions; + + /// + /// + /// In en, this message translates to: + /// **'Top processes'** + String get topProcesses; + + /// + /// + /// In en, this message translates to: + /// **'Mounted disks'** + String get mountedDisks; + + /// + /// + /// In en, this message translates to: + /// **'Linux Assistant inspects these settings without changing them. Every finding comes with the command to check or change it yourself.'** + String get securityReadOnlyIntro; + + /// + /// + /// In en, this message translates to: + /// **'min'** + String get uptimeUnitShortMinutes; + + /// + /// + /// In en, this message translates to: + /// **'h'** + String get uptimeUnitShortHours; + + /// + /// + /// In en, this message translates to: + /// **'d'** + String get uptimeUnitShortDays; + + /// + /// + /// In en, this message translates to: + /// **'of {total}'** + String ofTotal(String total); + + /// + /// + /// In en, this message translates to: + /// **'{count} updates available'** + String updatesAvailableCount(int count); + + /// + /// + /// In en, this message translates to: + /// **'A firewall blocks incoming connections you never intended to accept.'** + String get whyFirewall; + + /// + /// + /// In en, this message translates to: + /// **'An SSH server accepts logins over the network. If it runs unintentionally, it widens your attack surface.'** + String get whySsh; + + /// + /// + /// In en, this message translates to: + /// **'Without fail2ban, repeated failed SSH logins are never throttled or blocked.'** + String get whyFail2ban; + + /// + /// + /// In en, this message translates to: + /// **'A remote desktop server exposes your entire session over the network.'** + String get whyXrdp; + + /// + /// + /// In en, this message translates to: + /// **'A world-readable home directory lets any local account read your personal files.'** + String get whyHomeFolder; + + /// + /// + /// In en, this message translates to: + /// **'Pending updates frequently contain security fixes.'** + String get whyUpdates; + + /// + /// + /// In en, this message translates to: + /// **'Third-party repositories can ship packages that replace system ones.'** + String get whySources; + + /// + /// + /// In en, this message translates to: + /// **'AUR helpers install community packages that nobody reviewed for you.'** + String get whyYay; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 7ae7766..ed76bfb 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -962,4 +962,174 @@ class AppLocalizationsDe extends AppLocalizations { @override String get showPackageManagerActions => 'Paketmanager-Aktionen anzeigen'; + + @override + String get dashboard => 'Dashboard'; + + @override + String get hubStorage => 'Speicher'; + + @override + String get hubSoftware => 'Software & Updates'; + + @override + String get hubSystem => 'System'; + + @override + String get hubSearch => 'Suche'; + + @override + String get quickActions => 'Schnellaktionen'; + + @override + String get hubSearchPlaceholder => + 'Apps, Dateien, Einstellungen und Aktionen durchsuchen...'; + + @override + String get copyCommand => 'Befehl kopieren'; + + @override + String get copied => 'Kopiert'; + + @override + String get readOnly => 'Nur Lesen'; + + @override + String get readOnlyNotice => + 'Diese Ansicht liest dein System nur aus. Sie ändert nichts.'; + + @override + String get whyItMatters => 'Warum das relevant ist'; + + @override + String get checkItYourself => 'Selbst prüfen oder ändern'; + + @override + String get severityCritical => 'Kritisch'; + + @override + String get severityHigh => 'Hoch'; + + @override + String get severityMedium => 'Mittel'; + + @override + String get severityInfo => 'Info'; + + @override + String get uptimeLabel => 'Laufzeit'; + + @override + String get processesLabel => 'Prozesse'; + + @override + String get zombiesLabel => 'Zombies'; + + @override + String get swapLabel => 'Swap'; + + @override + String get themeSystem => 'System folgen'; + + @override + String get themeLight => 'Hell'; + + @override + String get themeDark => 'Dunkel'; + + @override + String get switchTheme => 'Design wechseln'; + + @override + String get useDistroColors => 'Farben der Distribution verwenden'; + + @override + String get useDistroColorsDescription => + 'Ersetzt die Standard-Akzentfarbe durch eine zur Distribution passende Farbe.'; + + @override + String get collectingData => 'Systemdaten werden erfasst...'; + + @override + String get statsUnavailable => + 'Systemdaten konnten nicht gelesen werden. Es werden die letzten bekannten Werte angezeigt.'; + + @override + String get kernelLabel => 'Kernel'; + + @override + String get desktopLabel => 'Desktop'; + + @override + String get hostLabel => 'Host'; + + @override + String get collapseSidebar => 'Seitenleiste einklappen'; + + @override + String get expandSidebar => 'Seitenleiste ausklappen'; + + @override + String get noQuickActions => 'Noch keine Schnellaktionen verfügbar.'; + + @override + String get topProcesses => 'Top-Prozesse'; + + @override + String get mountedDisks => 'Eingebundene Datenträger'; + + @override + String get securityReadOnlyIntro => + 'Linux Assistant prüft diese Einstellungen, ohne sie zu verändern. Zu jedem Befund gibt es den Befehl, um selbst nachzusehen oder etwas zu ändern.'; + + @override + String get uptimeUnitShortMinutes => 'Min.'; + + @override + String get uptimeUnitShortHours => 'Std.'; + + @override + String get uptimeUnitShortDays => 'Tg.'; + + @override + String ofTotal(String total) { + return 'von $total'; + } + + @override + String updatesAvailableCount(int count) { + return '$count Updates verfügbar'; + } + + @override + String get whyFirewall => + 'Eine Firewall blockiert eingehende Verbindungen, die du nie zulassen wolltest.'; + + @override + String get whySsh => + 'Ein SSH-Server nimmt Anmeldungen über das Netzwerk an. Läuft er ungewollt, vergrößert das die Angriffsfläche.'; + + @override + String get whyFail2ban => + 'Ohne fail2ban werden wiederholte fehlgeschlagene SSH-Anmeldungen weder gebremst noch blockiert.'; + + @override + String get whyXrdp => + 'Ein Remote-Desktop-Server gibt deine gesamte Sitzung über das Netzwerk frei.'; + + @override + String get whyHomeFolder => + 'Ein für alle lesbares Home-Verzeichnis erlaubt jedem lokalen Konto, deine persönlichen Dateien zu lesen.'; + + @override + String get whyUpdates => + 'Ausstehende Updates enthalten häufig Sicherheitskorrekturen.'; + + @override + String get whySources => + 'Fremdquellen können Pakete liefern, die Systempakete ersetzen.'; + + @override + String get whyYay => + 'AUR-Helfer installieren Community-Pakete, die niemand für dich geprüft hat.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6013f33..7768bcd 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -955,4 +955,173 @@ class AppLocalizationsEn extends AppLocalizations { @override String get showPackageManagerActions => 'Show package manager actions'; + + @override + String get dashboard => 'Dashboard'; + + @override + String get hubStorage => 'Storage'; + + @override + String get hubSoftware => 'Software & updates'; + + @override + String get hubSystem => 'System'; + + @override + String get hubSearch => 'Search'; + + @override + String get quickActions => 'Quick actions'; + + @override + String get hubSearchPlaceholder => + 'Search apps, files, settings and actions...'; + + @override + String get copyCommand => 'Copy command'; + + @override + String get copied => 'Copied'; + + @override + String get readOnly => 'Read-only'; + + @override + String get readOnlyNotice => + 'This view only reads your system. It never changes anything.'; + + @override + String get whyItMatters => 'Why this matters'; + + @override + String get checkItYourself => 'Check or change it yourself'; + + @override + String get severityCritical => 'Critical'; + + @override + String get severityHigh => 'High'; + + @override + String get severityMedium => 'Medium'; + + @override + String get severityInfo => 'Info'; + + @override + String get uptimeLabel => 'Uptime'; + + @override + String get processesLabel => 'Processes'; + + @override + String get zombiesLabel => 'Zombies'; + + @override + String get swapLabel => 'Swap'; + + @override + String get themeSystem => 'Follow system'; + + @override + String get themeLight => 'Light'; + + @override + String get themeDark => 'Dark'; + + @override + String get switchTheme => 'Switch theme'; + + @override + String get useDistroColors => 'Use distribution colors'; + + @override + String get useDistroColorsDescription => + 'Replaces the default accent with a color matching your distribution.'; + + @override + String get collectingData => 'Collecting system data...'; + + @override + String get statsUnavailable => + 'System data could not be read. Showing the last known values.'; + + @override + String get kernelLabel => 'Kernel'; + + @override + String get desktopLabel => 'Desktop'; + + @override + String get hostLabel => 'Host'; + + @override + String get collapseSidebar => 'Collapse sidebar'; + + @override + String get expandSidebar => 'Expand sidebar'; + + @override + String get noQuickActions => 'No quick actions available yet.'; + + @override + String get topProcesses => 'Top processes'; + + @override + String get mountedDisks => 'Mounted disks'; + + @override + String get securityReadOnlyIntro => + 'Linux Assistant inspects these settings without changing them. Every finding comes with the command to check or change it yourself.'; + + @override + String get uptimeUnitShortMinutes => 'min'; + + @override + String get uptimeUnitShortHours => 'h'; + + @override + String get uptimeUnitShortDays => 'd'; + + @override + String ofTotal(String total) { + return 'of $total'; + } + + @override + String updatesAvailableCount(int count) { + return '$count updates available'; + } + + @override + String get whyFirewall => + 'A firewall blocks incoming connections you never intended to accept.'; + + @override + String get whySsh => + 'An SSH server accepts logins over the network. If it runs unintentionally, it widens your attack surface.'; + + @override + String get whyFail2ban => + 'Without fail2ban, repeated failed SSH logins are never throttled or blocked.'; + + @override + String get whyXrdp => + 'A remote desktop server exposes your entire session over the network.'; + + @override + String get whyHomeFolder => + 'A world-readable home directory lets any local account read your personal files.'; + + @override + String get whyUpdates => 'Pending updates frequently contain security fixes.'; + + @override + String get whySources => + 'Third-party repositories can ship packages that replace system ones.'; + + @override + String get whyYay => + 'AUR helpers install community packages that nobody reviewed for you.'; } diff --git a/lib/l10n/app_localizations_fi.dart b/lib/l10n/app_localizations_fi.dart index 25a0e69..13ba25b 100644 --- a/lib/l10n/app_localizations_fi.dart +++ b/lib/l10n/app_localizations_fi.dart @@ -965,4 +965,173 @@ class AppLocalizationsFi extends AppLocalizations { @override String get showPackageManagerActions => 'Näytä paketinhallinnan toiminnot'; + + @override + String get dashboard => 'Dashboard'; + + @override + String get hubStorage => 'Storage'; + + @override + String get hubSoftware => 'Software & updates'; + + @override + String get hubSystem => 'System'; + + @override + String get hubSearch => 'Search'; + + @override + String get quickActions => 'Quick actions'; + + @override + String get hubSearchPlaceholder => + 'Search apps, files, settings and actions...'; + + @override + String get copyCommand => 'Copy command'; + + @override + String get copied => 'Copied'; + + @override + String get readOnly => 'Read-only'; + + @override + String get readOnlyNotice => + 'This view only reads your system. It never changes anything.'; + + @override + String get whyItMatters => 'Why this matters'; + + @override + String get checkItYourself => 'Check or change it yourself'; + + @override + String get severityCritical => 'Critical'; + + @override + String get severityHigh => 'High'; + + @override + String get severityMedium => 'Medium'; + + @override + String get severityInfo => 'Info'; + + @override + String get uptimeLabel => 'Uptime'; + + @override + String get processesLabel => 'Processes'; + + @override + String get zombiesLabel => 'Zombies'; + + @override + String get swapLabel => 'Swap'; + + @override + String get themeSystem => 'Follow system'; + + @override + String get themeLight => 'Light'; + + @override + String get themeDark => 'Dark'; + + @override + String get switchTheme => 'Switch theme'; + + @override + String get useDistroColors => 'Use distribution colors'; + + @override + String get useDistroColorsDescription => + 'Replaces the default accent with a color matching your distribution.'; + + @override + String get collectingData => 'Collecting system data...'; + + @override + String get statsUnavailable => + 'System data could not be read. Showing the last known values.'; + + @override + String get kernelLabel => 'Kernel'; + + @override + String get desktopLabel => 'Desktop'; + + @override + String get hostLabel => 'Host'; + + @override + String get collapseSidebar => 'Collapse sidebar'; + + @override + String get expandSidebar => 'Expand sidebar'; + + @override + String get noQuickActions => 'No quick actions available yet.'; + + @override + String get topProcesses => 'Top processes'; + + @override + String get mountedDisks => 'Mounted disks'; + + @override + String get securityReadOnlyIntro => + 'Linux Assistant inspects these settings without changing them. Every finding comes with the command to check or change it yourself.'; + + @override + String get uptimeUnitShortMinutes => 'min'; + + @override + String get uptimeUnitShortHours => 'h'; + + @override + String get uptimeUnitShortDays => 'd'; + + @override + String ofTotal(String total) { + return 'of $total'; + } + + @override + String updatesAvailableCount(int count) { + return '$count updates available'; + } + + @override + String get whyFirewall => + 'A firewall blocks incoming connections you never intended to accept.'; + + @override + String get whySsh => + 'An SSH server accepts logins over the network. If it runs unintentionally, it widens your attack surface.'; + + @override + String get whyFail2ban => + 'Without fail2ban, repeated failed SSH logins are never throttled or blocked.'; + + @override + String get whyXrdp => + 'A remote desktop server exposes your entire session over the network.'; + + @override + String get whyHomeFolder => + 'A world-readable home directory lets any local account read your personal files.'; + + @override + String get whyUpdates => 'Pending updates frequently contain security fixes.'; + + @override + String get whySources => + 'Third-party repositories can ship packages that replace system ones.'; + + @override + String get whyYay => + 'AUR helpers install community packages that nobody reviewed for you.'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 698954e..698192e 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -971,4 +971,174 @@ class AppLocalizationsIt extends AppLocalizations { @override String get showPackageManagerActions => 'Mostra azioni del gestore pacchetti'; + + @override + String get dashboard => 'Dashboard'; + + @override + String get hubStorage => 'Archiviazione'; + + @override + String get hubSoftware => 'Software e aggiornamenti'; + + @override + String get hubSystem => 'Sistema'; + + @override + String get hubSearch => 'Ricerca'; + + @override + String get quickActions => 'Azioni rapide'; + + @override + String get hubSearchPlaceholder => + 'Cerca app, file, impostazioni e azioni...'; + + @override + String get copyCommand => 'Copia comando'; + + @override + String get copied => 'Copiato'; + + @override + String get readOnly => 'Sola lettura'; + + @override + String get readOnlyNotice => + 'Questa vista legge solo il sistema. Non modifica nulla.'; + + @override + String get whyItMatters => 'Perché è importante'; + + @override + String get checkItYourself => 'Verifica o modifica tu stesso'; + + @override + String get severityCritical => 'Critico'; + + @override + String get severityHigh => 'Alto'; + + @override + String get severityMedium => 'Medio'; + + @override + String get severityInfo => 'Info'; + + @override + String get uptimeLabel => 'Tempo di attività'; + + @override + String get processesLabel => 'Processi'; + + @override + String get zombiesLabel => 'Zombie'; + + @override + String get swapLabel => 'Swap'; + + @override + String get themeSystem => 'Segui il sistema'; + + @override + String get themeLight => 'Chiaro'; + + @override + String get themeDark => 'Scuro'; + + @override + String get switchTheme => 'Cambia tema'; + + @override + String get useDistroColors => 'Usa i colori della distribuzione'; + + @override + String get useDistroColorsDescription => + 'Sostituisce il colore principale con uno adatto alla tua distribuzione.'; + + @override + String get collectingData => 'Raccolta dei dati di sistema...'; + + @override + String get statsUnavailable => + 'Impossibile leggere i dati di sistema. Vengono mostrati gli ultimi valori noti.'; + + @override + String get kernelLabel => 'Kernel'; + + @override + String get desktopLabel => 'Desktop'; + + @override + String get hostLabel => 'Host'; + + @override + String get collapseSidebar => 'Comprimi barra laterale'; + + @override + String get expandSidebar => 'Espandi barra laterale'; + + @override + String get noQuickActions => 'Nessuna azione rapida disponibile.'; + + @override + String get topProcesses => 'Processi principali'; + + @override + String get mountedDisks => 'Dischi montati'; + + @override + String get securityReadOnlyIntro => + 'Linux Assistant controlla queste impostazioni senza modificarle. Ogni risultato include il comando per verificarlo o modificarlo autonomamente.'; + + @override + String get uptimeUnitShortMinutes => 'min'; + + @override + String get uptimeUnitShortHours => 'h'; + + @override + String get uptimeUnitShortDays => 'g'; + + @override + String ofTotal(String total) { + return 'di $total'; + } + + @override + String updatesAvailableCount(int count) { + return '$count aggiornamenti disponibili'; + } + + @override + String get whyFirewall => + 'Un firewall blocca le connessioni in entrata che non intendevi accettare.'; + + @override + String get whySsh => + 'Un server SSH accetta accessi dalla rete. Se è attivo involontariamente, amplia la superficie di attacco.'; + + @override + String get whyFail2ban => + 'Senza fail2ban, i tentativi di accesso SSH falliti non vengono limitati né bloccati.'; + + @override + String get whyXrdp => + 'Un server desktop remoto espone l\'intera sessione sulla rete.'; + + @override + String get whyHomeFolder => + 'Una cartella home leggibile da tutti consente a qualsiasi account locale di leggere i tuoi file personali.'; + + @override + String get whyUpdates => + 'Gli aggiornamenti in sospeso contengono spesso correzioni di sicurezza.'; + + @override + String get whySources => + 'I repository di terze parti possono fornire pacchetti che sostituiscono quelli di sistema.'; + + @override + String get whyYay => + 'Gli helper AUR installano pacchetti della community che nessuno ha verificato per te.'; } diff --git a/pubspec.lock b/pubspec.lock index 75193bf..5bca476 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -217,26 +217,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -257,26 +257,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.18.0" mutex: dependency: "direct main" description: @@ -414,10 +414,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.11" typed_data: dependency: transitive description: @@ -470,10 +470,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -507,5 +507,5 @@ packages: source: hosted version: "6.5.0" sdks: - dart: ">=3.7.0-0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.22.0" diff --git a/test/hermes_tokens_test.dart b/test/hermes_tokens_test.dart new file mode 100644 index 0000000..c35a639 --- /dev/null +++ b/test/hermes_tokens_test.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; +import 'package:linux_assistant/layouts/mint_y.dart'; + +/// The pairs that carry text and therefore have to clear WCAG AA (4.5:1). +/// +/// Gold is a light hue, and the shipped Hermes light values for accent text, +/// success, warning and info all land below 4.5 on cream. This locks in the +/// darkened replacements so a future palette edit cannot quietly undo them. +List> _textPairs(HermesTokens t) => [ + ["text on bg", t.text, t.bg], + ["text on surface", t.text, t.surface], + ["text on sidebar", t.text, t.sidebar], + ["text on surfaceSubtle", t.text, t.surfaceSubtle], + ["strong on bg", t.strong, t.bg], + ["muted on bg", t.muted, t.bg], + ["muted on surface", t.muted, t.surface], + ["accentText on bg", t.accentText, t.bg], + ["accentText on accentBg", t.accentText, t.accentBg], + ["accentText on accentBgStrong", t.accentText, t.accentBgStrong], + ["onAccent on accent", t.onAccent, t.accent], + ["error on bg", t.error, t.bg], + ["success on bg", t.success, t.bg], + ["warning on bg", t.warning, t.bg], + ["info on bg", t.info, t.bg], + ["codeText on codeBg", t.codeText, t.codeBg], + ]; + +void main() { + group("contrast", () { + test("known ratios are computed correctly", () { + expect( + HermesTokens.contrastRatio( + const Color(0xFF000000), const Color(0xFFFFFFFF)), + closeTo(21.0, 0.01), + ); + expect( + HermesTokens.contrastRatio( + const Color(0xFF808080), const Color(0xFF808080)), + closeTo(1.0, 0.001), + ); + }); + + test("light palette text pairs meet WCAG AA", () { + for (final pair in _textPairs(HermesTokens.light)) { + final ratio = + HermesTokens.contrastRatio(pair[1] as Color, pair[2] as Color); + expect(ratio, greaterThanOrEqualTo(4.5), + reason: "light: ${pair[0]} is only " + "${ratio.toStringAsFixed(2)}:1"); + } + }); + + test("dark palette text pairs meet WCAG AA", () { + for (final pair in _textPairs(HermesTokens.dark)) { + final ratio = + HermesTokens.contrastRatio(pair[1] as Color, pair[2] as Color); + expect(ratio, greaterThanOrEqualTo(4.5), + reason: "dark: ${pair[0]} is only " + "${ratio.toStringAsFixed(2)}:1"); + } + }); + }); + + group("accessibleOn", () { + test("leaves an already-compliant color untouched", () { + const black = Color(0xFF000000); + expect( + HermesTokens.accessibleOn(black, const Color(0xFFFFFFFF)), + black, + ); + }); + + test("darkens a light hue on a light background until it passes", () { + // Raw gold on cream is about 3.2:1 and needs several steps down. + const gold = Color(0xFFFFD700); + const cream = Color(0xFFFEFCF7); + final fixed = HermesTokens.accessibleOn(gold, cream); + + expect(HermesTokens.contrastRatio(fixed, cream), + greaterThanOrEqualTo(4.5)); + expect(HermesTokens.relativeLuminance(fixed), + lessThan(HermesTokens.relativeLuminance(gold))); + }); + + test("lightens a dark hue on a dark background until it passes", () { + const navy = Color(0xFF1A237E); + const black = Color(0xFF0D0D1A); + final fixed = HermesTokens.accessibleOn(navy, black); + + expect(HermesTokens.contrastRatio(fixed, black), + greaterThanOrEqualTo(4.5)); + expect(HermesTokens.relativeLuminance(fixed), + greaterThan(HermesTokens.relativeLuminance(navy))); + }); + }); + + group("withAccent", () { + // Distribution accents the app may substitute for the Hermes gold. + const distroAccents = { + "ubuntu": Color(0xFFE95420), + "fedora": Color(0xFF51A2DA), + "mint": Color(0xFF35A854), + "debian": Color(0xFFD0074E), + "manjaro": Color(0xFF35BFA4), + }; + + test("keeps accent text readable on both tints in light mode", () { + distroAccents.forEach((name, accent) { + final t = HermesTokens.light.withAccent(accent); + expect(HermesTokens.contrastRatio(t.accentText, t.accentBg), + greaterThanOrEqualTo(4.5), + reason: "$name accentText on accentBg"); + expect(HermesTokens.contrastRatio(t.accentText, t.accentBgStrong), + greaterThanOrEqualTo(4.5), + reason: "$name accentText on accentBgStrong"); + }); + }); + + test("keeps button ink readable on the accent fill", () { + distroAccents.forEach((name, accent) { + for (final base in [HermesTokens.light, HermesTokens.dark]) { + final t = base.withAccent(accent); + expect(HermesTokens.contrastRatio(t.onAccent, t.accent), + greaterThanOrEqualTo(4.5), + reason: "$name onAccent on accent"); + } + }); + }); + }); + + group("theme wiring", () { + test("both themes expose a ColorScheme built from the tokens", () { + final light = MintY.theme(); + expect(light.colorScheme.primary, HermesTokens.light.accent); + expect(light.colorScheme.onPrimary, HermesTokens.light.onAccent); + expect(light.colorScheme.surface, HermesTokens.light.bg); + expect(light.brightness, Brightness.light); + + final dark = MintY.themeDark(); + expect(dark.colorScheme.primary, HermesTokens.dark.accent); + expect(dark.colorScheme.surface, HermesTokens.dark.bg); + expect(dark.brightness, Brightness.dark); + }); + + test("both themes carry the token extension", () { + expect(MintY.theme().extension(), HermesTokens.light); + expect(MintY.themeDark().extension(), HermesTokens.dark); + }); + + test("an accent override reaches the ColorScheme", () { + const ubuntu = Color(0xFFE95420); + final theme = MintY.theme(accent: ubuntu); + expect(theme.colorScheme.primary, ubuntu); + }); + + test("body text styles inherit their color from the theme", () { + // The styles must not hardcode black, or dark mode renders unreadable. + expect(MintY.paragraph.color, isNull); + expect(MintY.heading1.color, isNull); + expect(MintY.themeDark().textTheme.bodyMedium!.color, + HermesTokens.dark.text); + expect(MintY.theme().textTheme.bodyMedium!.color, + HermesTokens.light.text); + }); + + test("colorfulBackground tracks later color changes", () { + final original = MintY.currentColor; + addTearDown(() => MintY.currentColor = original); + + MintY.currentColor = const Color(0xFF123456); + final gradient = + MintY.colorfulBackground.gradient as LinearGradient; + expect(gradient.colors.first, const Color(0xFF123456)); + }); + }); +} diff --git a/test/hermes_widgets_test.dart b/test/hermes_widgets_test.dart new file mode 100644 index 0000000..301d9f2 --- /dev/null +++ b/test/hermes_widgets_test.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:linux_assistant/l10n/app_localizations.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; +import 'package:linux_assistant/layouts/hub/hub_grid.dart'; +import 'package:linux_assistant/layouts/mint_y.dart'; +import 'package:linux_assistant/layouts/security_check/security_finding.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_badge.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_card.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_halo_dot.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_nav_item.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_sparkline.dart'; +import 'package:linux_assistant/widgets/hermes/hermes_stat_tile.dart'; + +/// Wraps a widget in the app's real theme and localizations. +Widget _host(Widget child, {bool dark = false, Size size = const Size(900, 700)}) { + return MaterialApp( + theme: dark ? MintY.themeDark() : MintY.theme(), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('en', '')], + locale: const Locale('en', ''), + home: Scaffold( + body: SizedBox( + width: size.width, + height: size.height, + child: child, + ), + ), + ); +} + +void main() { + group("HermesCard", () { + testWidgets("renders its child", (tester) async { + await tester.pumpWidget(_host( + const HermesCard(child: Text("payload")), + )); + expect(find.text("payload"), findsOneWidget); + }); + + testWidgets("is flat: no drop shadow", (tester) async { + await tester.pumpWidget(_host( + const HermesCard(child: Text("payload")), + )); + + final container = tester.widget( + find + .ancestor( + of: find.text("payload"), + matching: find.byType(Container), + ) + .last, + ); + final decoration = container.decoration as BoxDecoration; + expect(decoration.boxShadow, anyOf(isNull, isEmpty)); + expect(decoration.border, isNotNull); + }); + + testWidgets("fires onTap", (tester) async { + int taps = 0; + await tester.pumpWidget(_host( + HermesCard(onTap: () => taps++, child: const Text("tap me")), + )); + + await tester.tap(find.text("tap me")); + await tester.pump(); + expect(taps, 1); + }); + }); + + group("HermesBadge", () { + testWidgets("shows its label", (tester) async { + await tester.pumpWidget(_host( + const HermesBadge(text: "P1 High", tone: HermesTone.error), + )); + expect(find.text("P1 High"), findsOneWidget); + }); + + test("every tone resolves to three distinct-purpose colors", () { + for (final tone in HermesTone.values) { + final colors = hermesToneColors(HermesTokens.light, tone); + expect(colors.fg, isNotNull); + expect(colors.bg, isNotNull); + expect(colors.border, isNotNull); + } + }); + }); + + testWidgets("HermesHaloDot renders", (tester) async { + await tester.pumpWidget(_host(const HermesHaloDot())); + expect(find.byType(HermesHaloDot), findsOneWidget); + }); + + group("HermesStatTile", () { + testWidgets("shows label, value and unit", (tester) async { + await tester.pumpWidget(_host( + const HermesStatTile( + label: "CPU usage", + icon: Icons.speed, + value: "42", + unit: "%", + ), + )); + + expect(find.text("CPU USAGE"), findsOneWidget); + expect(find.text("42"), findsOneWidget); + expect(find.text("%"), findsOneWidget); + }); + + testWidgets("falls back to a dash before data arrives", (tester) async { + await tester.pumpWidget(_host( + const HermesStatTile(label: "RAM", icon: Icons.memory), + )); + expect(find.text("—"), findsOneWidget); + }); + }); + + group("HermesSparkline", () { + testWidgets("renders with a normal series", (tester) async { + await tester.pumpWidget(_host( + const HermesSparkline(values: [0.1, 0.5, 0.3, 0.9, 0.4]), + )); + expect(tester.takeException(), isNull); + }); + + testWidgets("survives empty and single-point series", (tester) async { + await tester.pumpWidget(_host(const HermesSparkline(values: []))); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(_host(const HermesSparkline(values: [0.5]))); + expect(tester.takeException(), isNull); + }); + }); + + group("HermesNavItem", () { + testWidgets("selected item reports itself to accessibility", + (tester) async { + await tester.pumpWidget(_host( + HermesNavItem( + icon: Icons.dashboard, + label: "Dashboard", + selected: true, + onTap: () {}, + ), + )); + + expect(find.text("Dashboard"), findsOneWidget); + final semantics = tester.getSemantics(find.byType(HermesNavItem)); + expect(semantics.hasFlag(SemanticsFlag.isSelected), isTrue); + }); + + testWidgets("hides the label when collapsed", (tester) async { + await tester.pumpWidget(_host( + HermesNavItem( + icon: Icons.dashboard, + label: "Dashboard", + selected: false, + collapsed: true, + onTap: () {}, + ), + )); + expect(find.text("Dashboard"), findsNothing); + }); + }); + + group("HubGrid", () { + testWidgets("uses more columns as the window widens", (tester) async { + Future tileWidthAt(double width) async { + await tester.pumpWidget(_host( + HubGrid( + minTileWidth: 260, + children: List.generate(4, (i) => Text("tile$i")), + ), + size: Size(width, 700), + )); + await tester.pump(); + return tester.getSize(find.ancestor( + of: find.text("tile0"), + matching: find.byType(SizedBox), + ).first).width; + } + + final narrow = await tileWidthAt(300); + final wide = await tileWidthAt(1200); + + // One column when narrow, several when wide: the tile itself shrinks. + expect(narrow, greaterThan(wide)); + }); + + testWidgets("renders nothing when empty", (tester) async { + await tester.pumpWidget(_host(const HubGrid(children: []))); + expect(tester.takeException(), isNull); + }); + }); + + group("SecurityFindingTile", () { + testWidgets("shows severity, explanation and the command", (tester) async { + await tester.pumpWidget(_host( + const SecurityFindingTile( + finding: SecurityFinding( + severity: FindingSeverity.high, + title: "Firewall is inactive", + why: "A firewall blocks incoming connections.", + command: "sudo ufw status verbose", + ), + ), + )); + + expect(find.text("Firewall is inactive"), findsOneWidget); + expect(find.textContaining("P1"), findsOneWidget); + expect(find.text("sudo ufw status verbose"), findsOneWidget); + expect(find.textContaining("A firewall blocks"), findsOneWidget); + }); + + testWidgets("offers no button that changes the system", (tester) async { + await tester.pumpWidget(_host( + const SecurityFindingTile( + finding: SecurityFinding( + severity: FindingSeverity.high, + title: "Firewall is inactive", + command: "sudo ufw status verbose", + ), + ), + )); + + // The only interactive control is "copy to clipboard". + final buttons = find.byType(IconButton).evaluate(); + expect(buttons.length, 1); + expect(find.byIcon(Icons.copy), findsOneWidget); + expect(find.byType(ElevatedButton), findsNothing); + }); + + testWidgets("an OK finding carries no command", (tester) async { + await tester.pumpWidget(_host( + const SecurityFindingTile( + finding: SecurityFinding( + severity: FindingSeverity.ok, + title: "Firewall is running", + ), + ), + )); + + expect(find.text("OK"), findsOneWidget); + expect(find.byType(IconButton), findsNothing); + }); + + testWidgets("renders in dark mode too", (tester) async { + await tester.pumpWidget(_host( + const SecurityFindingTile( + finding: SecurityFinding( + severity: FindingSeverity.medium, + title: "Updates pending", + command: "apt list --upgradable", + ), + ), + dark: true, + )); + + expect(tester.takeException(), isNull); + expect(find.text("Updates pending"), findsOneWidget); + }); + }); +} diff --git a/test/system_parsers_test.dart b/test/system_parsers_test.dart new file mode 100644 index 0000000..dd40666 --- /dev/null +++ b/test/system_parsers_test.dart @@ -0,0 +1,197 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:linux_assistant/linux/linux_filesystem.dart'; +import 'package:linux_assistant/linux/linux_process.dart'; +import 'package:linux_assistant/linux/linux_system.dart'; +import 'package:linux_assistant/services/system_stats_service.dart'; + +void main() { + group("df parser", () { + test("keeps real filesystems and drops pseudo ones", () { + const output = ''' +Filesystem Size Used Avail Use% Mounted on +udev 7,8G 0 7,8G 0% /dev +tmpfs 1,6G 2,1M 1,6G 1% /run +/dev/nvme0n1p2 468G 212G 233G 48% / +/dev/nvme0n1p1 511M 6,1M 505M 2% /boot/efi +/dev/sdb1 59G 12G 47G 21% /media/user/USB +'''; + + final disks = LinuxFilesystem.parseDfOutput(output); + + expect(disks.map((d) => d.mountPoint), + containsAll(["/", "/media/user/USB"])); + expect(disks.any((d) => d.mountPoint == "/dev"), isFalse); + expect(disks.any((d) => d.mountPoint == "/run"), isFalse); + + final root = disks.firstWhere((d) => d.mountPoint == "/"); + expect(root.filesystem, "/dev/nvme0n1p2"); + expect(root.usedPercent, 48); + expect(root.sizeFree, "233G"); + expect(root.isRemovable, isFalse); + + final usb = disks.firstWhere((d) => d.mountPoint == "/media/user/USB"); + expect(usb.isRemovable, isTrue); + }); + + test("skips megabyte-sized partitions and duplicate devices", () { + const output = ''' +Filesystem Size Used Avail Use% Mounted on +/dev/sda1 511M 6,1M 505M 2% /boot/efi +/dev/sda2 468G 212G 233G 48% / +/dev/sda2 468G 212G 233G 48% /home +'''; + + final disks = LinuxFilesystem.parseDfOutput(output); + + expect(disks.length, 1); + expect(disks.single.mountPoint, "/"); + }); + + test("returns an empty list for empty output", () { + expect(LinuxFilesystem.parseDfOutput(""), isEmpty); + }); + }); + + group("ps parser", () { + test("reads the metric and strips the command path", () { + const output = ''' +%CPU COMMAND + 42.0 /usr/lib/firefox/firefox + 11.5 /usr/bin/gnome-shell + 3.2 /usr/bin/Xorg +'''; + + final processes = LinuxProcess.parsePsOutput(output, 3); + + expect(processes.length, 3); + expect(processes.first.metricValue, "42.0"); + expect(processes.first.processName, "firefox"); + expect(processes[1].processName, "gnome-shell"); + }); + + test("honours the requested count", () { + const output = ''' +%CPU COMMAND + 42.0 /usr/bin/a + 11.5 /usr/bin/b + 3.2 /usr/bin/c +'''; + expect(LinuxProcess.parsePsOutput(output, 2).length, 2); + }); + + test("skips lines that carry no command instead of throwing", () { + // ps emits bare metric lines for some kernel threads. + const output = ''' +%CPU COMMAND + 42.0 /usr/bin/a + 0.0 + 3.2 /usr/bin/c +'''; + + final processes = LinuxProcess.parsePsOutput(output, 3); + expect(processes.map((p) => p.processName), ["a", "c"]); + }); + }); + + group("uptime parser", () { + test("reads hours and minutes from the clock format", () { + final uptime = LinuxSystem.parseUptime( + " 14:23:01 up 3:45, 1 user, load average: 0,52, 0,58, 0,59"); + expect(uptime.unit, "h"); + expect(uptime.value, 3); + }); + + test("reports minutes when the hour component is zero", () { + final uptime = LinuxSystem.parseUptime( + " 09:12:44 up 0:27, 1 user, load average: 0,10, 0,20, 0,30"); + expect(uptime.unit, "m"); + expect(uptime.value, 27); + }); + + test("reads the 'min' wording", () { + final uptime = LinuxSystem.parseUptime( + " 09:12:44 up 42 min, 1 user, load average: 0,10, 0,20, 0,30"); + expect(uptime.unit, "m"); + expect(uptime.value, 42); + }); + + test("reads the 'days' wording", () { + final uptime = LinuxSystem.parseUptime( + " 09:12:44 up 12 days, 3:21, 2 users, load average: 0,10"); + expect(uptime.unit, "d"); + expect(uptime.value, 12); + }); + }); + + group("free parser", () { + test("reads memory and swap totals", () { + const output = ''' + total used free shared buff/cache available +Mem: 15889 6421 1204 512 8263 8901 +Swap: 2047 128 1919 +'''; + + final memory = MemoryInfo.parseFreeOutput(output)!; + + expect(memory.totalMb, 15889); + expect(memory.usedMb, 6421); + expect(memory.swapTotalMb, 2047); + expect(memory.swapUsedMb, 128); + expect(memory.hasSwap, isTrue); + expect(memory.usedRatio, closeTo(6421 / 15889, 0.0001)); + }); + + test("handles a machine without swap", () { + const output = ''' + total used free shared buff/cache available +Mem: 7861 2100 3000 200 2761 5400 +Swap: 0 0 0 +'''; + + final memory = MemoryInfo.parseFreeOutput(output)!; + expect(memory.hasSwap, isFalse); + expect(memory.swapUsedRatio, 0); + }); + + test("returns null rather than throwing on unusable output", () { + expect(MemoryInfo.parseFreeOutput(""), isNull); + expect(MemoryInfo.parseFreeOutput("some error\n"), isNull); + expect(MemoryInfo.parseFreeOutput("header\nMem: not a number\n"), isNull); + }); + }); + + group("stats service", () { + test("reference counting starts and stops the poller", () { + final service = SystemStatsService(); + addTearDown(service.resetForTesting); + service.resetForTesting(); + + expect(service.isRunning, isFalse); + + service.acquire(); + expect(service.subscriberCount, 1); + expect(service.isRunning, isTrue); + + service.acquire(); + expect(service.subscriberCount, 2); + + service.release(); + // Still one listener left, so polling must continue. + expect(service.isRunning, isTrue); + + service.release(); + expect(service.subscriberCount, 0); + expect(service.isRunning, isFalse); + }); + + test("releasing below zero is a no-op", () { + final service = SystemStatsService(); + addTearDown(service.resetForTesting); + service.resetForTesting(); + + service.release(); + expect(service.subscriberCount, 0); + expect(service.isRunning, isFalse); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 8dc49f9..ec0a545 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,33 +1,90 @@ -// This is a basic Flutter widget test. +// Theme plumbing tests. // -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. +// This file previously held the unmodified Flutter counter-app template, which +// asserted on a counter and an add button that this app has never had, and so +// could not pass. It now covers the wiring that the rest of the UI depends on: +// that widgets can reach the design tokens, and that light and dark differ. import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:linux_assistant/layouts/greeter/start_screen.dart'; - -import 'package:linux_assistant/main.dart'; +import 'package:linux_assistant/layouts/hermes_tokens.dart'; +import 'package:linux_assistant/layouts/mint_y.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(MyApp( - firstPage: const StartScreen(), + testWidgets("widgets read the tokens through the theme extension", + (tester) async { + late HermesTokens seen; + + await tester.pumpWidget(MaterialApp( + theme: MintY.theme(), + home: Builder(builder: (context) { + seen = HermesTokens.of(context); + return const SizedBox(); + }), + )); + + expect(seen, HermesTokens.light); + }); + + testWidgets("dark theme delivers the dark tokens", (tester) async { + late HermesTokens seen; + + await tester.pumpWidget(MaterialApp( + theme: MintY.themeDark(), + home: Builder(builder: (context) { + seen = HermesTokens.of(context); + return const SizedBox(); + }), )); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + expect(seen, HermesTokens.dark); + expect(seen.bg, isNot(HermesTokens.light.bg)); + }); + + testWidgets( + "falls back to the brightness-matched palette without the extension", + (tester) async { + late HermesTokens seen; + + // A bare ThemeData, as used by widget tests that do not build the app. + await tester.pumpWidget(MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Builder(builder: (context) { + seen = HermesTokens.of(context); + return const SizedBox(); + }), + )); + + expect(seen, HermesTokens.dark); + }); + + testWidgets("themeMode switches the palette in place", (tester) async { + // MaterialApp builds ColoredBoxes of its own, so tag the one under test. + const probe = Key("palette-probe"); + + Widget app(ThemeMode mode) => MaterialApp( + theme: MintY.theme(), + darkTheme: MintY.themeDark(), + themeMode: mode, + home: Builder(builder: (context) { + return ColoredBox( + key: probe, + color: HermesTokens.of(context).bg, + ); + }), + ); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); + await tester.pumpWidget(app(ThemeMode.light)); + expect( + tester.widget(find.byKey(probe)).color, + HermesTokens.light.bg, + ); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + await tester.pumpWidget(app(ThemeMode.dark)); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(probe)).color, + HermesTokens.dark.bg, + ); }); } From 38ee1d7d8621e2df03513be7625adf4c1461d6c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 13:07:04 +0000 Subject: [PATCH 6/6] Drop the launcher gradient when search is a hub section The search painted MintY.colorfulBackground across its whole body. As a full-window launcher overlay that is the point; as a panel inside the hub it filled the content area with a gold gradient that fought with the sidebar and top bar. The gradient is now tied to standalone use. The colorfulBackground setting still applies when the search is the whole window. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HioAo2j6ufA9GsyKBWF69u --- lib/layouts/hub/hub_shell.dart | 2 +- lib/layouts/main_screen/main_search.dart | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/lib/layouts/hub/hub_shell.dart b/lib/layouts/hub/hub_shell.dart index bccf503..278989e 100644 --- a/lib/layouts/hub/hub_shell.dart +++ b/lib/layouts/hub/hub_shell.dart @@ -125,7 +125,7 @@ class _HubShellState extends State { onOpenSecurity: () => _select(HubSection.security), ); case HubSection.search: - return MainSearch(); + return MainSearch(embedded: true); case HubSection.storage: return const StorageSection(); case HubSection.health: diff --git a/lib/layouts/main_screen/main_search.dart b/lib/layouts/main_screen/main_search.dart index a271abd..e4fd97f 100644 --- a/lib/layouts/main_screen/main_search.dart +++ b/lib/layouts/main_screen/main_search.dart @@ -29,12 +29,19 @@ import 'package:linux_assistant/l10n/app_localizations.dart'; class MainSearch extends StatefulWidget { late bool colorfulBackground; - MainSearch({Key? key}) { + /// True when the search is a section of the hub rather than the whole window. + final bool embedded; + + MainSearch({super.key, this.embedded = false}) { ConfigHandler configHandler = ConfigHandler(); - colorfulBackground = configHandler.getValueUnsafe( - "colorfulBackground", - true, - ); + // The gradient is a full-window launcher backdrop. Inside the hub it + // covers the content area only and fights with the surrounding chrome, so + // the section stays on the theme's own surface. + colorfulBackground = !embedded && + configHandler.getValueUnsafe( + "colorfulBackground", + true, + ); } /// Set by the hub while it is on screen.