Dark/bright: Auto (Android's dark theme), Dark or Bright, kept across restarts
/ crosscheck (push) Successful in 1s
CI / Analyze & Test (push) Successful in 19s
CI / Debug APK (push) Successful in 1m5s

Settings has a segmented Auto / Dark / Bright. Auto follows platformBrightness, so
Android's schedule too. The Dash's quick button holds the other variant; a long press
is auto again. The mode is kept with shared_preferences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 22:14:29 +09:00
parent 0d0940e6b7
commit 78a6564f0a
8 changed files with 285 additions and 28 deletions
+6
View File
@@ -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
+4 -1
View File
@@ -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.
+13 -7
View File
@@ -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,
),
),
),
],
+31 -7
View File
@@ -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<VariantMode>(
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),
+60 -12
View File
@@ -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<Themes> 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<String, dynamic>),
Palette._fromJson(j['bright'] as Map<String, dynamic>),
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,
+134 -1
View File
@@ -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"
+1
View File
@@ -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:
+36
View File
@@ -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
},
);
}