Auto dark/bright follows the sun at the tablet's own location
/ crosscheck (push) Successful in 1s
CI / Analyze & Test (push) Successful in 22s
CI / Debug APK (push) Successful in 39s

Without a light sensor an app may read (the Redmi Pad SE has none), auto is dark while
the sun is more than 4° below the horizon (lib/theme/sun.dart), at Android's coarse
location, polled and kept across restarts. The tablet's own, not the Orin's GNSS: the
tablet has its own GNSS and cellular. Android's dark theme is the last fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 22:33:43 +09:00
parent 317de3425a
commit 31a844bb94
9 changed files with 164 additions and 19 deletions
+2
View File
@@ -3,6 +3,8 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- requestNetwork for the USB (NCM) Ethernet link to the Orin --> <!-- requestNetwork for the USB (NCM) Ethernet link to the Orin -->
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
<!-- Where the tablet is, roughly: for the sun (auto dark/bright, lib/theme/sun.dart). -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<application <application
android:label="Yukart" android:label="Yukart"
android:name="${applicationName}" android:name="${applicationName}"
@@ -1,15 +1,19 @@
package dev.yukart.display package dev.yukart.display
import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.hardware.Sensor import android.hardware.Sensor
import android.hardware.SensorEvent import android.hardware.SensorEvent
import android.hardware.SensorEventListener import android.hardware.SensorEventListener
import android.hardware.SensorManager import android.hardware.SensorManager
import android.location.LocationManager
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager import android.view.WindowManager
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
@@ -29,6 +33,7 @@ import io.flutter.plugin.common.MethodChannel
* *
* Also the light sensor, for auto dark/bright (lib/theme/light.dart): lux on `yukart/light` * 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). * while Dart listens, where the device lets apps read one (the Redmi Pad SE does not).
* And, for the sun instead, the tablet's own rough location on `yukart/location`.
* *
* 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.
@@ -39,6 +44,7 @@ class MainActivity : FlutterActivity() {
private var ethernet: Network? = null private var ethernet: Network? = null
private var wantBinding = false private var wantBinding = false
private var pendingLinkTest: String? = null private var pendingLinkTest: String? = null
private var askedLocation = false
private val callback = object : ConnectivityManager.NetworkCallback() { private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
@@ -90,10 +96,38 @@ class MainActivity : FlutterActivity() {
.build() .build()
cm.requestNetwork(request, callback) cm.requestNetwork(request, callback)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "yukart/location").setMethodCallHandler { call, result ->
if (call.method == "last") result.success(lastLocation()) else result.notImplemented()
}
EventChannel(flutterEngine.dartExecutor.binaryMessenger, "yukart/light") EventChannel(flutterEngine.dartExecutor.binaryMessenger, "yukart/light")
.setStreamHandler(LightStream(getSystemService(Context.SENSOR_SERVICE) as SensorManager)) .setStreamHandler(LightStream(getSystemService(Context.SENSOR_SERVICE) as SensorManager))
} }
/**
* [lat, lon] of the newest location Android has (other apps' fixes included), or null.
* Asks for coarse location the first time; with none known, asks for one, which the
* next call sees.
*/
private fun lastLocation(): List<Double>? {
if (checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
if (!askedLocation) {
askedLocation = true
requestPermissions(arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION), 1)
}
return null
}
val lm = getSystemService(Context.LOCATION_SERVICE) as LocationManager
val enabled = lm.getProviders(true)
val best = enabled.mapNotNull { runCatching { lm.getLastKnownLocation(it) }.getOrNull() }.maxByOrNull { it.time }
if (best == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
listOf("fused", LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
.firstOrNull { it in enabled }
?.let { runCatching { lm.getCurrentLocation(it, null, mainExecutor) {} } }
}
return best?.let { listOf(it.latitude, it.longitude) }
}
/** Lux from the default light sensor while listened to; an error at once without one, or when refused. */ /** 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 class LightStream(private val sm: SensorManager) : EventChannel.StreamHandler {
private var listener: SensorEventListener? = null private var listener: SensorEventListener? = null
+9 -4
View File
@@ -81,10 +81,15 @@ where the device lets an app read one (`theme/light.dart`: dark at ≤ 15 lux, b
held 5 s; Settings shows the reading). **The Redmi Pad SE does not:** its only light sensor, 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 `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 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: exposed either (`screen_brightness` is the manual level). Google Maps here
the Orin's camera (its exposure and gain say how bright the road is; svc-vision would publish reads no light sensor either (checked while it navigated in its night colours): it goes by
it), opening the tablet's front camera ourselves (camera permission, the in-use dot, and the time and place, and tunnels from map data.
camera's power), or sunrise and sunset from GNSS position and time (no sensor; tunnels ignored).
**Sun, done (2026-10-15):** without a light sensor, auto follows the sun at the tablet's own
rough location (Android's coarse location, polled; kept across restarts; not the Orin's GNSS,
since the tablet has its own GNSS and cellular): dark below −4° (`theme/sun.dart`). Android's
dark theme is the last fallback, before any location is known. Tunnels and car parks are not
seen; the quick button covers them.
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:
+1 -1
View File
@@ -42,7 +42,7 @@ pirate-station-aide's (main views, then the rarely used ones grouped as Tools).
`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` (a light sensor where the device allows one, `theme/light.dart`; - **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 else the sun at the tablet's own location, `theme/sun.dart`; else Android's dark theme), `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
+12 -9
View File
@@ -1,16 +1,18 @@
// Yukart display: the tablet app. Starts the link to the Orin and hands the screen to // Yukart display: the tablet app. Starts the link to the Orin and hands the screen to
// the shell (lib/shell.dart). Code layout: lib/README.md. // the shell (lib/shell.dart). Code layout: lib/README.md.
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'link/boundary.dart'; import 'link/boundary.dart';
import 'link/net.dart'; import 'link/net.dart';
import 'link/reading.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/light.dart';
import 'theme/palette.dart'; import 'theme/palette.dart';
import 'theme/sun.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/`.
const defaultUrl = String.fromEnvironment( const defaultUrl = String.fromEnvironment(
@@ -25,14 +27,15 @@ Future<void> main() async {
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();
// The sun, for auto dark/bright: the car's position while GNSS has one. // The sun, for auto dark/bright: the tablet's own location, every minute until there
boundary.addListener(() { // is one (the first ask brings up the permission), then every ten.
final lat = Reading.of(boundary, 'gnss_lat'); Future<void> locate() async {
final lon = Reading.of(boundary, 'gnss_lon'); final p = await androidLocation();
if (!lat.dim && !lon.dim && lat.number != null && lon.number != null) { if (p != null) themes.setPosition(p.$1, p.$2);
themes.setPosition(lat.number!, lon.number!); Timer(Duration(minutes: p == null ? 1 : 10), locate);
} }
});
locate();
final linkTest = LinkTest(usb); final linkTest = LinkTest(usb);
// Development hook: `am start ... --es linktest [dl:|bi:]<label>[@<host>]` runs the test // Development hook: `am start ... --es linktest [dl:|bi:]<label>[@<host>]` runs the test
// (`dl:` the download series, `bi:` burst latency against idle gaps), on the gateway's host unless one is given. // (`dl:` the download series, `bi:` burst latency against idle gaps), on the gateway's host unless one is given.
+3 -3
View File
@@ -65,9 +65,9 @@ class SettingsPage extends StatelessWidget {
? 'now ${lux.toStringAsFixed(lux < 10 ? 1 : 0)} lux; dark at ' ? 'now ${lux.toStringAsFixed(lux < 10 ? 1 : 0)} lux; dark at '
'${LightSwitch.darkAt.round()} or less, bright from ${LightSwitch.brightAt.round()}' '${LightSwitch.darkAt.round()} or less, bright from ${LightSwitch.brightAt.round()}'
: sun != null : sun != null
? 'now ${sun.round()}° above the horizon at the last GNSS position; ' ? 'the sun is ${sun.abs().round()}° ${sun < 0 ? 'below' : 'above'} the horizon here; '
'dark below ${darkBelowDeg.round()}°' 'dark from ${(-darkBelowDeg).round()}° below'
: 'no GNSS position yet'; : 'no location yet';
return Text( return Text(
'Auto follows ${themes.autoSource} ($now). ' 'Auto follows ${themes.autoSource} ($now). '
'The status bar button holds one; a long press there is auto again.', 'The status bar button holds one; a long press there is auto again.',
+2 -2
View File
@@ -127,7 +127,7 @@ 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.
/// In auto it follows a light sensor ([followLight]) where the device lets an app read /// In auto it follows a light sensor ([followLight]) where the device lets an app read
/// one; else the sun at the car's last position ([setPosition]); else Android's dark /// one; else the sun at the tablet's last location ([setPosition]); else Android's dark
/// theme (by hand or on its schedule). The mode and the position are kept across restarts. /// theme (by hand or on its schedule). The mode and the position are kept across restarts.
class Themes extends ChangeNotifier with WidgetsBindingObserver { class Themes extends ChangeNotifier with WidgetsBindingObserver {
Themes( Themes(
@@ -210,7 +210,7 @@ class Themes extends ChangeNotifier with WidgetsBindingObserver {
bool? _sunDark; bool? _sunDark;
late final Timer _sunTimer; late final Timer _sunTimer;
/// The car's position, for the sun. Kept (to 0.1°, about 10 km: plenty for the sun) /// The tablet's location, for the sun. Kept (to 0.1°, about 10 km: plenty for the sun)
/// so auto knows the sun before the first fix. /// so auto knows the sun before the first fix.
void setPosition(double lat, double lon) { void setPosition(double lat, double lon) {
double r(double x) => (x * 10).round() / 10; double r(double x) => (x * 10).round() / 10;
+48
View File
@@ -0,0 +1,48 @@
// Auto dark/bright from the sun: dark once it is far enough below the horizon, at the
// tablet's own rough location (Android's, not the Orin's: the tablet stands on its own).
// Google Maps on this tablet does the same (it reads no light sensor; checked). Needs no
// sensor; does not know about tunnels or car parks.
import 'dart:math' as math;
import 'package:flutter/services.dart';
/// The tablet's newest known location, roughly, from MainActivity; null without one
/// (or before location is granted: the first call asks).
Future<(double, double)?> androidLocation() async {
try {
final v = await const MethodChannel('yukart/location')
.invokeListMethod<double>('last');
return v == null || v.length < 2 ? null : (v[0], v[1]);
} catch (_) {
return null; // No Android side (tests, desktop).
}
}
/// Dark while the sun is lower than this (degrees; sunset is -0.8, civil dusk -6).
const darkBelowDeg = -4.0;
/// The sun's elevation above the horizon, in degrees, at [lat], [lon] (degrees, east
/// positive) and [time]. The usual low-precision formulae (the Astronomical Almanac's,
/// as NOAA uses them): within about a degree, plenty for this.
double sunElevation(double lat, double lon, DateTime time) {
double rad(double d) => d * math.pi / 180;
double deg(double r) => r * 180 / math.pi;
final d =
time.toUtc().millisecondsSinceEpoch / 86400000 + 2440587.5 - 2451545.0;
final g = rad(357.529 + 0.98560028 * d); // mean anomaly
final q = 280.459 + 0.98564736 * d; // mean longitude
final l = rad(q + 1.915 * math.sin(g) + 0.020 * math.sin(2 * g));
final e = rad(23.439 - 0.00000036 * d); // obliquity
final ra = math.atan2(math.cos(e) * math.sin(l), math.cos(l));
final dec = math.asin(math.sin(e) * math.sin(l));
final gmst = (18.697374558 + 24.06570982441908 * d) % 24;
final ha = rad(gmst * 15 + lon) - ra;
final phi = rad(lat);
return deg(
math.asin(
math.sin(phi) * math.sin(dec) +
math.cos(phi) * math.cos(dec) * math.cos(ha),
),
);
}
+53
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:display/theme/light.dart'; import 'package:display/theme/light.dart';
import 'package:display/theme/palette.dart'; import 'package:display/theme/palette.dart';
import 'package:display/theme/sun.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';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -69,5 +70,57 @@ void main() {
const Duration(seconds: 6), const Duration(seconds: 6),
); // no new reading: the timer ends the hold ); // no new reading: the timer ends the hold
expect(t.isDark, isTrue); expect(t.isDark, isTrue);
t.dispose();
}); });
test(
'the sun: high at noon, below at midnight, sets near the almanac time',
() {
// Tokyo, 2026-06-21: noon (03:00 UTC) about 77°, midnight well below.
expect(
sunElevation(35.68, 139.77, DateTime.utc(2026, 6, 21, 2, 43)),
closeTo(77.7, 1),
);
expect(
sunElevation(35.68, 139.77, DateTime.utc(2026, 6, 21, 15)),
lessThan(-20),
);
// Helsinki, 2026-10-15: sunset 18:14 EEST (15:14 UTC): -0.8° there.
expect(
sunElevation(60.17, 24.94, DateTime.utc(2026, 10, 15, 15, 14)),
closeTo(-0.8, 0.6),
);
},
);
test(
'auto: the sun before Android, the light sensor before the sun',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
dispatcher.platformBrightnessTestValue = Brightness.light;
var now = DateTime.utc(2026, 6, 21, 15); // Tokyo midnight
final t = Themes(
't',
Palette.fallbackDark,
Palette.fallbackBright,
prefs,
() => now,
);
expect((t.isDark, t.autoSource), (false, "Android's dark theme"));
t.setPosition(35.68, 139.77);
expect((t.isDark, t.autoSource), (true, 'the sun'));
now = DateTime.utc(2026, 6, 21, 3);
final again = Themes(
't',
Palette.fallbackDark,
Palette.fallbackBright,
prefs,
() => now,
);
expect(again.isDark, isFalse); // position kept; Tokyo noon
t.dispose();
again.dispose();
},
);
} }