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
+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),
),
);
}