Dark/bright: Auto (Android's dark theme), Dark or Bright, kept across restarts
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user