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"/>
<!-- requestNetwork for the USB (NCM) Ethernet link to the Orin -->
<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
android:label="Yukart"
android:name="${applicationName}"
@@ -1,15 +1,19 @@
package dev.yukart.display
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.location.LocationManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
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`
* 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>]`
* 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 wantBinding = false
private var pendingLinkTest: String? = null
private var askedLocation = false
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
@@ -90,10 +96,38 @@ class MainActivity : FlutterActivity() {
.build()
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")
.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. */
private class LightStream(private val sm: SensorManager) : EventChannel.StreamHandler {
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,
`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).
exposed either (`screen_brightness` is the manual level). Google Maps here
reads no light sensor either (checked while it navigated in its night colours): it goes by
time and place, and tunnels from map data.
**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:
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
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`;
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;
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
+12 -9
View File
@@ -1,16 +1,18 @@
// 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.
import 'dart:async';
import 'package:flutter/material.dart';
import 'link/boundary.dart';
import 'link/net.dart';
import 'link/reading.dart';
import 'pages/link_test.dart';
import 'shell.dart';
import 'shell_scope.dart';
import 'theme/light.dart';
import 'theme/palette.dart';
import 'theme/sun.dart';
/// Override for development, e.g. `--dart-define=YUKART_GW=ws://halogen.local:7700/`.
const defaultUrl = String.fromEnvironment(
@@ -25,14 +27,15 @@ Future<void> main() async {
final usb = UsbLink();
await usb.bindFor(Uri.parse(defaultUrl));
final boundary = Boundary(defaultUrl)..start();
// The sun, for auto dark/bright: the car's position while GNSS has one.
boundary.addListener(() {
final lat = Reading.of(boundary, 'gnss_lat');
final lon = Reading.of(boundary, 'gnss_lon');
if (!lat.dim && !lon.dim && lat.number != null && lon.number != null) {
themes.setPosition(lat.number!, lon.number!);
}
});
// The sun, for auto dark/bright: the tablet's own location, every minute until there
// is one (the first ask brings up the permission), then every ten.
Future<void> locate() async {
final p = await androidLocation();
if (p != null) themes.setPosition(p.$1, p.$2);
Timer(Duration(minutes: p == null ? 1 : 10), locate);
}
locate();
final linkTest = LinkTest(usb);
// 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.
+3 -3
View File
@@ -65,9 +65,9 @@ class SettingsPage extends StatelessWidget {
? 'now ${lux.toStringAsFixed(lux < 10 ? 1 : 0)} lux; dark at '
'${LightSwitch.darkAt.round()} or less, bright from ${LightSwitch.brightAt.round()}'
: sun != null
? 'now ${sun.round()}° above the horizon at the last GNSS position; '
'dark below ${darkBelowDeg.round()}°'
: 'no GNSS position yet';
? 'the sun is ${sun.abs().round()}° ${sun < 0 ? 'below' : 'above'} the horizon here; '
'dark from ${(-darkBelowDeg).round()}° below'
: 'no location yet';
return Text(
'Auto follows ${themes.autoSource} ($now). '
'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.
/// 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.
class Themes extends ChangeNotifier with WidgetsBindingObserver {
Themes(
@@ -210,7 +210,7 @@ class Themes extends ChangeNotifier with WidgetsBindingObserver {
bool? _sunDark;
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.
void setPosition(double lat, double lon) {
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/palette.dart';
import 'package:display/theme/sun.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -69,5 +70,57 @@ void main() {
const Duration(seconds: 6),
); // no new reading: the timer ends the hold
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();
},
);
}