Auto dark/bright takes a light sensor where the device allows one
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:
@@ -2,6 +2,10 @@ package dev.yukart.display
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.hardware.Sensor
|
||||||
|
import android.hardware.SensorEvent
|
||||||
|
import android.hardware.SensorEventListener
|
||||||
|
import android.hardware.SensorManager
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.Network
|
import android.net.Network
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
@@ -10,6 +14,7 @@ import android.os.Bundle
|
|||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,6 +27,9 @@ import io.flutter.plugin.common.MethodChannel
|
|||||||
* When asked, the whole process is bound to the Ethernet (NCM) network while it
|
* When asked, the whole process is bound to the Ethernet (NCM) network while it
|
||||||
* exists, and unbound when it goes.
|
* exists, and unbound when it goes.
|
||||||
*
|
*
|
||||||
|
* Also the light sensor, for auto dark/bright (lib/theme/light.dart): lux on `yukart/light`
|
||||||
|
* while Dart listens, where the device lets apps read one (the Redmi Pad SE does not).
|
||||||
|
*
|
||||||
* Also a development hook: `am start -n dev.yukart.display/.MainActivity --es linktest [dl:|bi:]<label>[@<host>]`
|
* Also a development hook: `am start -n dev.yukart.display/.MainActivity --es linktest [dl:|bi:]<label>[@<host>]`
|
||||||
* runs the link test (lib/linktest.dart) without touching the screen.
|
* runs the link test (lib/linktest.dart) without touching the screen.
|
||||||
*/
|
*/
|
||||||
@@ -81,6 +89,34 @@ class MainActivity : FlutterActivity() {
|
|||||||
.removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
.removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
.build()
|
.build()
|
||||||
cm.requestNetwork(request, callback)
|
cm.requestNetwork(request, callback)
|
||||||
|
|
||||||
|
EventChannel(flutterEngine.dartExecutor.binaryMessenger, "yukart/light")
|
||||||
|
.setStreamHandler(LightStream(getSystemService(Context.SENSOR_SERVICE) as SensorManager))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lux from the default light sensor while listened to; an error at once without one, or when refused. */
|
||||||
|
private class LightStream(private val sm: SensorManager) : EventChannel.StreamHandler {
|
||||||
|
private var listener: SensorEventListener? = null
|
||||||
|
|
||||||
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||||
|
val sensor = sm.getDefaultSensor(Sensor.TYPE_LIGHT)
|
||||||
|
?: return events.error("no_sensor", "no light sensor", null)
|
||||||
|
listener = object : SensorEventListener {
|
||||||
|
override fun onSensorChanged(e: SensorEvent) = events.success(e.values[0].toDouble())
|
||||||
|
override fun onAccuracyChanged(s: Sensor, accuracy: Int) {}
|
||||||
|
}
|
||||||
|
// The Redmi Pad SE lists one (derived from the front camera) but refuses apps:
|
||||||
|
// only the system's auto-brightness may read it.
|
||||||
|
if (!sm.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)) {
|
||||||
|
listener = null
|
||||||
|
events.error("refused", "light sensor refused", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(arguments: Any?) {
|
||||||
|
listener?.let { sm.unregisterListener(it) }
|
||||||
|
listener = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun apply() {
|
private fun apply() {
|
||||||
|
|||||||
@@ -74,7 +74,17 @@ chosen readouts.
|
|||||||
**Done (2026-10-15):** Settings has Auto / Dark / Bright (a segmented control, kept across
|
**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
|
restarts). Auto follows Android's dark theme (`platformBrightness`, so its schedule too). The
|
||||||
status bar's button (it moved there with item 1) holds the other variant, and a long press
|
status bar's button (it moved there with item 1) holds the other variant, and a long press
|
||||||
returns it to auto. Not done: the light sensor as an auto source.
|
returns it to auto.
|
||||||
|
|
||||||
|
**Light sensor, tried (2026-10-15):** Auto takes a light sensor over Android's dark theme
|
||||||
|
where the device lets an app read one (`theme/light.dart`: dark at ≤ 15 lux, bright from 60,
|
||||||
|
held 5 s; Settings shows the reading). **The Redmi Pad SE does not:** its only light sensor,
|
||||||
|
`camera_light_Sensor`, is derived by Xiaomi from the front camera, and `registerListener` refuses
|
||||||
|
apps at every rate (only the system's auto-brightness reads it). Its automatic brightness is not
|
||||||
|
exposed either (`screen_brightness` is the manual level). Other lux sources, if wanted:
|
||||||
|
the Orin's camera (its exposure and gain say how bright the road is; svc-vision would publish
|
||||||
|
it), opening the tablet's front camera ourselves (camera permission, the in-use dot, and the
|
||||||
|
camera's power), or sunrise and sunset from GNSS position and time (no sensor; tunnels ignored).
|
||||||
What was asked:
|
What was asked:
|
||||||
|
|
||||||
Today the variant is a switch (the Dash's top-right button, and Settings). Wanted:
|
Today the variant is a switch (the Dash's top-right button, and Settings). Wanted:
|
||||||
|
|||||||
+2
-1
@@ -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
|
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
|
`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`).
|
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;
|
`bright`, chosen in Settings and kept across restarts. The status bar's button holds the other variant;
|
||||||
a long press there is auto again.
|
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
|
- **Cosmetic fonts are opt-in.** A widget that wants the HUD face names `fontFamily: hudFont` in its own
|
||||||
|
|||||||
+3
-1
@@ -8,6 +8,7 @@ import 'link/net.dart';
|
|||||||
import 'pages/link_test.dart';
|
import 'pages/link_test.dart';
|
||||||
import 'shell.dart';
|
import 'shell.dart';
|
||||||
import 'shell_scope.dart';
|
import 'shell_scope.dart';
|
||||||
|
import 'theme/light.dart';
|
||||||
import 'theme/palette.dart';
|
import 'theme/palette.dart';
|
||||||
|
|
||||||
/// Override for development, e.g. `--dart-define=YUKART_GW=ws://halogen.local:7700/`.
|
/// 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 {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
final themes = await Themes.load();
|
final themes = await Themes.load()
|
||||||
|
..followLight(lightLevels());
|
||||||
final usb = UsbLink();
|
final usb = UsbLink();
|
||||||
await usb.bindFor(Uri.parse(defaultUrl));
|
await usb.bindFor(Uri.parse(defaultUrl));
|
||||||
final boundary = Boundary(defaultUrl)..start();
|
final boundary = Boundary(defaultUrl)..start();
|
||||||
|
|||||||
+11
-3
@@ -7,6 +7,7 @@ import '../actions/server_url.dart';
|
|||||||
import '../link/boundary.dart';
|
import '../link/boundary.dart';
|
||||||
import '../link/net.dart';
|
import '../link/net.dart';
|
||||||
import '../shell_scope.dart';
|
import '../shell_scope.dart';
|
||||||
|
import '../theme/light.dart';
|
||||||
import '../theme/palette.dart';
|
import '../theme/palette.dart';
|
||||||
|
|
||||||
class SettingsPage extends StatelessWidget {
|
class SettingsPage extends StatelessWidget {
|
||||||
@@ -55,9 +56,16 @@ class SettingsPage extends StatelessWidget {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(themes.isDark ? Icons.dark_mode : Icons.light_mode),
|
leading: Icon(themes.isDark ? Icons.dark_mode : Icons.light_mode),
|
||||||
title: const Text('Dark or bright'),
|
title: const Text('Dark or bright'),
|
||||||
subtitle: const Text(
|
subtitle: ValueListenableBuilder(
|
||||||
"Auto follows Android's dark theme and its schedule. "
|
valueListenable: themes.lux,
|
||||||
"The Dash's button holds one; a long press there is auto again.",
|
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(
|
Padding(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
-5
@@ -15,6 +15,7 @@
|
|||||||
//
|
//
|
||||||
// Pick the theme at build time: `--dart-define=YUKART_THEME=default` (default: rei, smart-serow's).
|
// Pick the theme at build time: `--dart-define=YUKART_THEME=default` (default: rei, smart-serow's).
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math' show max, min;
|
import 'dart:math' show max, min;
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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
|
/// 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.
|
/// TextStyle; everything else keeps the basic font, which covers CJK where DIN does not.
|
||||||
const hudFont = 'DIN1451';
|
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 }
|
enum VariantMode { auto, dark, bright }
|
||||||
|
|
||||||
/// A theme: both variants, and which one is showing. Listen to it to follow a switch,
|
/// 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).
|
/// In auto it follows the tablet's light sensor ([followLight]); without one, Android's
|
||||||
/// The mode is kept across restarts.
|
/// dark theme (by hand or on its schedule). The mode is kept across restarts.
|
||||||
class Themes extends ChangeNotifier with WidgetsBindingObserver {
|
class Themes extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
Themes(this.name, this.dark, this.bright, [this._prefs]) {
|
Themes(this.name, this.dark, this.bright, [this._prefs]) {
|
||||||
_mode =
|
_mode =
|
||||||
@@ -148,8 +151,48 @@ class Themes extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
notifyListeners();
|
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) {
|
bool get isDark => switch (_mode) {
|
||||||
VariantMode.auto =>
|
VariantMode.auto =>
|
||||||
|
_light.isDark ??
|
||||||
WidgetsBinding.instance.platformDispatcher.platformBrightness ==
|
WidgetsBinding.instance.platformDispatcher.platformBrightness ==
|
||||||
Brightness.dark,
|
Brightness.dark,
|
||||||
VariantMode.dark => true,
|
VariantMode.dark => true,
|
||||||
@@ -163,12 +206,14 @@ class Themes extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangePlatformBrightness() {
|
void didChangePlatformBrightness() {
|
||||||
if (_mode == VariantMode.auto) notifyListeners();
|
if (_mode == VariantMode.auto && _light.isDark == null) notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_holdTimer?.cancel();
|
||||||
|
lux.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:display/theme/light.dart';
|
||||||
import 'package:display/theme/palette.dart';
|
import 'package:display/theme/palette.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -33,4 +36,38 @@ void main() {
|
|||||||
expect(make().mode, VariantMode.dark); // kept for the next start
|
expect(make().mode, VariantMode.dark); // kept for the next start
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('the light switch has a gap and a hold', () {
|
||||||
|
final s = LightSwitch();
|
||||||
|
final t0 = DateTime(2026);
|
||||||
|
DateTime at(int sec) => t0.add(Duration(seconds: sec));
|
||||||
|
expect(s.feed(30, at(0)), isNull); // in the gap: no verdict yet
|
||||||
|
expect(s.feed(500, at(1)), isFalse); // the first clear reading decides
|
||||||
|
expect(s.feed(30, at(2)), isFalse); // the gap keeps it
|
||||||
|
expect(s.feed(5, at(3)), isFalse); // dark, but not for long enough
|
||||||
|
expect(s.pending, isTrue);
|
||||||
|
expect(s.feed(200, at(4)), isFalse); // back: the hold restarts
|
||||||
|
expect(s.pending, isFalse);
|
||||||
|
s.feed(5, at(10));
|
||||||
|
expect(s.feed(5, at(14)), isFalse);
|
||||||
|
expect(s.feed(5, at(15)), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('auto takes the light sensor over Android', (tester) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark;
|
||||||
|
final t = Themes('t', Palette.fallbackDark, Palette.fallbackBright, prefs);
|
||||||
|
final levels = StreamController<double>();
|
||||||
|
t.followLight(levels.stream);
|
||||||
|
expect(t.isDark, isTrue); // no reading yet: Android's
|
||||||
|
levels.add(800);
|
||||||
|
await tester.pump();
|
||||||
|
expect((t.isDark, t.lux.value), (false, 800));
|
||||||
|
levels.add(2);
|
||||||
|
await tester.pump(
|
||||||
|
const Duration(seconds: 6),
|
||||||
|
); // no new reading: the timer ends the hold
|
||||||
|
expect(t.isDark, isTrue);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user