Auto dark/bright follows the sun at the tablet's own location
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+11
-8
@@ -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.
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user