Auto dark/bright takes a light sensor where the device allows one
/ crosscheck (push) Successful in 1s
CI / Analyze & Test (push) Successful in 21s
CI / Debug APK (push) Successful in 41s

lib/theme/light.dart: dark at 15 lux or less, bright from 60, held 5 s; Settings shows the
reading. The Redmi Pad SE refuses apps its only light sensor (Xiaomi's, from the front
camera), so there Auto keeps following Android's dark theme. DASH-NEXT.md records the
other lux sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 22:24:36 +09:00
parent dcafc04cbc
commit 3398da3823
8 changed files with 215 additions and 13 deletions
+2 -1
View File
@@ -41,7 +41,8 @@ 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
- **Dark or bright.** `Themes.mode` is `auto` (a light sensor where the device allows one, `theme/light.dart`;
else Android's dark theme, schedule included), `dark` or
`bright`, chosen in Settings and kept across restarts. The status bar's 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
+3 -1
View File
@@ -8,6 +8,7 @@ import 'link/net.dart';
import 'pages/link_test.dart';
import 'shell.dart';
import 'shell_scope.dart';
import 'theme/light.dart';
import 'theme/palette.dart';
/// Override for development, e.g. `--dart-define=YUKART_GW=ws://halogen.local:7700/`.
@@ -18,7 +19,8 @@ const defaultUrl = String.fromEnvironment(
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final themes = await Themes.load();
final themes = await Themes.load()
..followLight(lightLevels());
final usb = UsbLink();
await usb.bindFor(Uri.parse(defaultUrl));
final boundary = Boundary(defaultUrl)..start();
+11 -3
View File
@@ -7,6 +7,7 @@ import '../actions/server_url.dart';
import '../link/boundary.dart';
import '../link/net.dart';
import '../shell_scope.dart';
import '../theme/light.dart';
import '../theme/palette.dart';
class SettingsPage extends StatelessWidget {
@@ -55,9 +56,16 @@ class SettingsPage extends StatelessWidget {
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.",
subtitle: ValueListenableBuilder(
valueListenable: themes.lux,
builder: (context, lux, _) => Text(
lux == null
? "Auto follows Android's dark theme and its schedule (no light sensor). "
'The status bar button holds one; a long press there is auto again.'
: 'Auto follows the light sensor: dark at ${LightSwitch.darkAt.round()} lux or less, '
'bright from ${LightSwitch.brightAt.round()}; now ${lux.toStringAsFixed(lux < 10 ? 1 : 0)} lux. '
'The status bar button holds one; a long press there is auto again.',
),
),
),
Padding(
+63
View File
@@ -0,0 +1,63 @@
// Auto dark/bright from the tablet's light sensor. The Redmi Pad SE has no separate one:
// Xiaomi derives it from the front camera ("camera_light_Sensor") and offers it as
// Android's ordinary light sensor, the one auto-brightness reads. No camera permission.
import 'package:flutter/services.dart';
/// Light levels in lux, about once a second, from MainActivity. Ends at once (an error,
/// which [Themes.followLight] ignores) where there is no sensor or no Android side.
Stream<double> lightLevels() =>
const EventChannel('yukart/light')
.receiveBroadcastStream()
.map((e) => (e as num).toDouble());
/// Dark or bright from light levels, with a gap and a hold so dusk, shade and
/// passing lights do not flicker it.
class LightSwitch {
/// Dark at or below this.
static const darkAt = 15.0;
/// Bright at or above this; in between, whatever it was.
static const brightAt = 60.0;
/// How long a level must last before it switches.
static const hold = Duration(seconds: 5);
bool? _dark;
DateTime? _since;
bool? get isDark => _dark;
/// A change is waiting out its hold.
bool get pending => _since != null;
/// The hold has passed with no reading against it (a sensor that reports only on
/// change goes quiet): make the waiting change.
void settle() {
if (_since == null) return;
_dark = !_dark!;
_since = null;
}
/// Feed one reading; returns the verdict (null until a reading outside the gap).
bool? feed(double lux, DateTime now) {
final want = lux <= darkAt
? true
: lux >= brightAt
? false
: _dark;
if (_dark == null) {
// The first clear reading decides at once; one in the gap leaves it open.
_dark = want;
} else if (want == _dark) {
_since = null;
} else {
_since ??= now;
if (now.difference(_since!) >= hold) {
_dark = want;
_since = null;
}
}
return _dark;
}
}
+52 -7
View File
@@ -15,6 +15,7 @@
//
// Pick the theme at build time: `--dart-define=YUKART_THEME=default` (default: rei, smart-serow's).
import 'dart:async';
import 'dart:convert';
import 'dart:math' show max, min;
@@ -22,6 +23,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'light.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.
const hudFont = 'DIN1451';
@@ -117,12 +120,12 @@ class Palette {
);
}
/// Which variant shows: Android's own dark theme (auto), or one held fixed.
/// Which variant shows: auto (the light sensor, else Android's dark theme), 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.
/// A theme: both variants, and which one is showing. Listen to it to follow a switch.
/// In auto it follows the tablet's light sensor ([followLight]); without one, Android's
/// dark theme (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 =
@@ -148,10 +151,50 @@ class Themes extends ChangeNotifier with WidgetsBindingObserver {
notifyListeners();
}
final _light = LightSwitch();
Timer? _holdTimer;
/// The last light level, in lux, while a sensor reports (for Settings, to tune by).
final lux = ValueNotifier<double?>(null);
/// Follow a light sensor in auto. Errors (no sensor) leave auto on Android's dark theme.
void followLight(Stream<double> levels) {
void changed(bool? before) {
if (_light.isDark != before && _mode == VariantMode.auto) {
notifyListeners();
}
}
void feed(double l) {
final before = _light.isDark;
_light.feed(l, DateTime.now());
changed(before);
// A sensor may report only on change: finish a hold even if none comes.
_holdTimer?.cancel();
if (_light.pending) {
_holdTimer = Timer(LightSwitch.hold, () {
final before = _light.isDark;
_light.settle();
changed(before);
});
}
}
levels.listen(
(l) {
lux.value = l;
feed(l);
},
onError: (_) => lux.value = null,
cancelOnError: true,
);
}
bool get isDark => switch (_mode) {
VariantMode.auto =>
WidgetsBinding.instance.platformDispatcher.platformBrightness ==
Brightness.dark,
_light.isDark ??
WidgetsBinding.instance.platformDispatcher.platformBrightness ==
Brightness.dark,
VariantMode.dark => true,
VariantMode.bright => false,
};
@@ -163,12 +206,14 @@ class Themes extends ChangeNotifier with WidgetsBindingObserver {
@override
void didChangePlatformBrightness() {
if (_mode == VariantMode.auto) notifyListeners();
if (_mode == VariantMode.auto && _light.isDark == null) notifyListeners();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_holdTimer?.cancel();
lux.dispose();
super.dispose();
}