diff --git a/docs/impl/plans/DASH-NEXT.md b/docs/impl/plans/DASH-NEXT.md index ead3d9d..f4ab0de 100644 --- a/docs/impl/plans/DASH-NEXT.md +++ b/docs/impl/plans/DASH-NEXT.md @@ -63,6 +63,12 @@ chosen readouts. ## 5. Dark/bright: automatic or fixed, the Android way +**Done (2026-10-15):** Settings has Auto / Dark / Bright (a segmented control, kept across +restarts). Auto follows Android's dark theme (`platformBrightness`, so its schedule too). The +Dash's button holds the other variant, and a long press returns it to auto. Not done: the +ambient light sensor as an auto source, and moving the button into the banner (item 1). +What was asked: + Today the variant is a switch (the Dash's top-right button, and Settings). Wanted: - **Auto or fixed**, chosen in Settings. Auto could follow Android's own dark mode diff --git a/lib/README.md b/lib/README.md index 372c992..1eee7bd 100644 --- a/lib/README.md +++ b/lib/README.md @@ -16,7 +16,7 @@ pirate-station-aide's (main views, then the rarely used ones grouped as Tools). | `pages/` | One file per view: `dash`, `signals` (values and raw traffic), `checklist` (Captures), `camera`, `link_test`, `settings` | | `actions/` | Things done from anywhere, as dialogs and sheets: `marks` (free-form mark), `orin_power` (power off / reboot), `server_url` (gateway address) | | `link/` | Talking to the Orin: `boundary` (the protocol client), `net` (binding to the USB link), `reading` (one signal's state as a display needs it) | -| `theme/` | `palette.dart`: the palette roles and `hudFont` | +| `theme/` | `palette.dart`: the palette roles, `hudFont`, and `Themes` (which variant shows: auto or held, kept across restarts) | | `widgets/` | Reusable display widgets, one per file, each fed values, never the boundary (`link_status` takes it whole: it shows the link) | ## Widgets @@ -40,6 +40,9 @@ pirate-station-aide's (main views, then the rarely used ones grouped as Tools). and the state gradient `good` → `caution` → `alarm`; plus the derived `surface` (raised bars) and `onFill(colour)` (text on a fill). No widget names a colour. Themes are JSON in `assets/themes/`, `rei` by default; smart-serow's four-key files still load (their `highlight` is `alarm`). +- **Dark or bright.** `Themes.mode` is `auto` (Android's dark theme, schedule included), `dark` or + `bright`, chosen in Settings and kept across restarts. The Dash's quick button holds the other variant; + a long press there is auto again. - **Cosmetic fonts are opt-in.** A widget that wants the HUD face names `fontFamily: hudFont` in its own `TextStyle`; everything else keeps the basic font, which covers CJK where DIN 1451 does not. - **Missing is a state** (app guideline 2): a widget given no value draws "—" or subdued, never an error. diff --git a/lib/pages/dash.dart b/lib/pages/dash.dart index fe4438f..94a28fc 100644 --- a/lib/pages/dash.dart +++ b/lib/pages/dash.dart @@ -164,7 +164,7 @@ class DashPage extends StatelessWidget { } } -/// The dash's own thin top line: the menu, link state, and the dark/bright switch. +/// The dash's own thin top line: the menu, link state, and the dark/bright quick switch. class _DashBar extends StatelessWidget { const _DashBar({required this.boundary, required this.themes}); final Boundary boundary; @@ -185,12 +185,18 @@ class _DashBar extends StatelessWidget { MenuButton(color: p.subdued), Text(text, style: TextStyle(color: color, letterSpacing: 2)), const Spacer(), - IconButton( - tooltip: themes.isDark ? 'Bright' : 'Dark', - onPressed: themes.toggle, - icon: Icon( - themes.isDark ? Icons.light_mode : Icons.dark_mode, - color: p.subdued, + // Tap: hold the other variant. Long press: back to auto (Settings has both). + GestureDetector( + onLongPress: () => themes.mode = VariantMode.auto, + child: IconButton( + tooltip: themes.isDark ? 'Bright' : 'Dark', + onPressed: themes.toggle, + icon: Icon( + themes.mode == VariantMode.auto + ? Icons.brightness_auto + : (themes.isDark ? Icons.light_mode : Icons.dark_mode), + color: p.subdued, + ), ), ), ], diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index 239cb4b..d0460fb 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -52,14 +52,38 @@ class SettingsPage extends StatelessWidget { onTap: () => editUrl(context, boundary, usb), ), section('Display'), - SwitchListTile( - secondary: Icon( - themes.isDark ? Icons.dark_mode : Icons.light_mode, + ListTile( + leading: Icon(themes.isDark ? Icons.dark_mode : Icons.light_mode), + title: const Text('Dark or bright'), + subtitle: const Text( + "Auto follows Android's dark theme and its schedule. " + "The Dash's button holds one; a long press there is auto again.", + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(72, 0, 16, 8), + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: VariantMode.auto, + icon: Icon(Icons.brightness_auto), + label: Text('Auto'), + ), + ButtonSegment( + value: VariantMode.dark, + icon: Icon(Icons.dark_mode), + label: Text('Dark'), + ), + ButtonSegment( + value: VariantMode.bright, + icon: Icon(Icons.light_mode), + label: Text('Bright'), + ), + ], + selected: {themes.mode}, + showSelectedIcon: false, + onSelectionChanged: (s) => themes.mode = s.single, ), - title: const Text('Dark variant'), - subtitle: const Text('Bright for daylight, dark for night'), - value: themes.isDark, - onChanged: (v) => themes.isDark = v, ), ListTile( leading: const Icon(Icons.palette), diff --git a/lib/theme/palette.dart b/lib/theme/palette.dart index bafd208..5682149 100644 --- a/lib/theme/palette.dart +++ b/lib/theme/palette.dart @@ -20,6 +20,7 @@ import 'dart:math' show max, min; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; /// The HUD face (smart-serow's DIN 1451). Opt-in: a widget names it in its own /// TextStyle; everything else keeps the basic font, which covers CJK where DIN does not. @@ -116,29 +117,70 @@ class Palette { ); } -/// A theme: both variants, and which one is showing. Listen to it to follow a switch. -class Themes extends ChangeNotifier { - Themes(this.name, this.dark, this.bright); +/// Which variant shows: Android's own dark theme (auto), or one held fixed. +enum VariantMode { auto, dark, bright } + +/// A theme: both variants, and which one is showing. Listen to it to follow a switch, +/// and, in auto, Android's dark theme turning on or off (by hand or on its schedule). +/// The mode is kept across restarts. +class Themes extends ChangeNotifier with WidgetsBindingObserver { + Themes(this.name, this.dark, this.bright, [this._prefs]) { + _mode = + VariantMode.values.asNameMap()[_prefs?.getString(_key)] ?? + VariantMode.auto; + WidgetsBinding.instance.addObserver(this); + } + + static const _key = 'variant_mode'; final String name; final Palette dark; final Palette bright; - bool _isDark = true; + final SharedPreferences? _prefs; + late VariantMode _mode; - bool get isDark => _isDark; - Palette get current => _isDark ? dark : bright; + VariantMode get mode => _mode; - set isDark(bool v) { - if (v == _isDark) return; - _isDark = v; + set mode(VariantMode v) { + if (v == _mode) return; + _mode = v; + _prefs?.setString(_key, v.name); notifyListeners(); } - void toggle() => isDark = !_isDark; + bool get isDark => switch (_mode) { + VariantMode.auto => + WidgetsBinding.instance.platformDispatcher.platformBrightness == + Brightness.dark, + VariantMode.dark => true, + VariantMode.bright => false, + }; + + Palette get current => isDark ? dark : bright; + + /// The quick switch: hold the other variant fixed. + void toggle() => mode = isDark ? VariantMode.bright : VariantMode.dark; + + @override + void didChangePlatformBrightness() { + if (_mode == VariantMode.auto) notifyListeners(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } /// [themeName], falling back to `default`, then to the hard-coded palette /// (smart-serow's chain). static Future load([String name = themeName]) async { + SharedPreferences? prefs; + try { + prefs = await SharedPreferences.getInstance(); + } catch (_) { + // No storage: the mode starts at auto each time. + } for (final n in {name, 'default'}) { try { final j = jsonDecode( @@ -148,19 +190,25 @@ class Themes extends ChangeNotifier { n, Palette._fromJson(j['dark'] as Map), Palette._fromJson(j['bright'] as Map), + prefs, ); } catch (_) { // Missing or malformed: try the next one. } } - return Themes('fallback', Palette.fallbackDark, Palette.fallbackBright); + return Themes( + 'fallback', + Palette.fallbackDark, + Palette.fallbackBright, + prefs, + ); } /// Material's theme for the rest of the app, from the same roles: buttons and /// selection in accent, errors in alarm, raised surfaces in surface. ThemeData material() { final p = current; - final brightness = _isDark ? Brightness.dark : Brightness.light; + final brightness = isDark ? Brightness.dark : Brightness.light; return ThemeData( useMaterial3: true, brightness: brightness, diff --git a/pubspec.lock b/pubspec.lock index a830a96..baa6192 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -75,6 +91,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" leak_tracker: dependency: transitive description: @@ -139,6 +160,102 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -208,6 +325,22 @@ packages: url: "https://pub.dev" source: hosted version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: dart: ">=3.13.1 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index df9229d..8418e31 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + shared_preferences: ^2.5.6 dev_dependencies: flutter_test: diff --git a/test/palette_test.dart b/test/palette_test.dart new file mode 100644 index 0000000..17408dc --- /dev/null +++ b/test/palette_test.dart @@ -0,0 +1,36 @@ +import 'package:display/theme/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final dispatcher = TestWidgetsFlutterBinding.instance.platformDispatcher; + tearDown(dispatcher.clearPlatformBrightnessTestValue); + + test( + 'auto follows Android, the quick switch holds one, and it is kept', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + Themes make() => + Themes('t', Palette.fallbackDark, Palette.fallbackBright, prefs); + final t = make(); + var notified = 0; + t.addListener(() => notified++); + + expect(t.mode, VariantMode.auto); + dispatcher.platformBrightnessTestValue = Brightness.light; + expect(t.isDark, isFalse); + expect(notified, 1); // the binding tells its observers + + t.toggle(); // bright showing: hold dark + expect((t.mode, t.isDark), (VariantMode.dark, true)); + dispatcher.platformBrightnessTestValue = Brightness.dark; + dispatcher.platformBrightnessTestValue = Brightness.light; + expect(notified, 2); // fixed: Android's changes are ignored + + expect(make().mode, VariantMode.dark); // kept for the next start + }, + ); +}