diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/home.dart | 158 | ||||
| -rw-r--r-- | lib/jotai.dart | 190 | ||||
| -rwxr-xr-x | lib/main.dart | 38 | ||||
| -rw-r--r-- | lib/settings.dart | 297 |
4 files changed, 683 insertions, 0 deletions
diff --git a/lib/home.dart b/lib/home.dart new file mode 100644 index 0000000..dc80128 --- /dev/null +++ b/lib/home.dart @@ -0,0 +1,158 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:region_settings/region_settings.dart'; +import 'package:speedometer/jotai.dart'; + +import 'main.dart'; +import 'settings.dart'; + +extension on SpeedUnit { + double fromMetersPerSecond(double metersPerSecond) => switch (this) { + SpeedUnit.kilometersPerHour => metersPerSecond * 3.6, + SpeedUnit.milesPerHour => metersPerSecond * 2.236936, + }; + + String get acronym => switch (this) { + SpeedUnit.kilometersPerHour => 'kmph', + SpeedUnit.milesPerHour => 'mph', + }; +} + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State<HomePage> createState() => _HomePageState(); +} + +class _HomePageState extends State<HomePage> { + StreamSubscription<Position>? _positionStream; + + // I was not able to get StreamBuilder or FutureBuilder to work correctly, + // so these are stored manually. + bool _usesMetric = true; + double _speed = 0.0; + double _speedAccuracy = 0.0; + + void _initPositionStream(LocationAccuracy locationAccuracy) { + setState( + () => _positionStream = + Geolocator.getPositionStream( + locationSettings: AndroidSettings( + accuracy: locationAccuracy, + forceLocationManager: true, + ), + ).listen((Position? position) { + if (position != null) { + setState(() { + _speed = position.speed; + _speedAccuracy = position.speedAccuracy; + }); + } else { + log("Position was null"); + } + }), + ); + _positionStream?.onError((error, stack) { + _positionStream?.cancel(); + logObservable.value = logObservable.value..add('$error\n$stack'); + Timer(Duration(seconds: 1), () => _initPositionStream(locationAccuracy)); + }); + } + + @override + void initState() { + super.initState(); + + Geolocator.checkPermission() + .then( + (permission) => permission == LocationPermission.denied + ? Geolocator.requestPermission() + : permission, + ) + .then((permission) { + if (![ + LocationPermission.deniedForever, + LocationPermission.denied, + ].contains(permission)) { + _initPositionStream(locationAccuracyObservable.value); + } else { + log("Location permission was denied"); + } + }) + .catchError((error, stack) { + log("$error\n$stack"); + }); + + locationAccuracyObservable.subscribe((locationAccuracy) { + _positionStream?.cancel(); + _initPositionStream(locationAccuracy); + }); + + // It takes time to load this data in, but I've only noticed it in debug mode + RegionSettings.getUsesMetricSystem().then( + (usesMetricSystem) => setState(() => _usesMetric = usesMetricSystem), + ); + } + + @override + void dispose() { + super.dispose(); + _positionStream?.cancel(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + actions: [ + // This button is big enough and descriptive enough to be easily + // discoverable, but small enough to not get in the way. + TextButton.icon( + icon: Icon(Icons.settings), + label: Text('Settings'), + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (context) => SettingsPage())), + ), + ], + ), + body: Center( + child: ObserverBuilder( + observable: speedUnitsObservable, + builder: (context, speedUnitsSetting, _) { + final speedUnits = + speedUnitsSetting ?? + (_usesMetric + ? SpeedUnit.kilometersPerHour + : SpeedUnit.milesPerHour); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flex(direction: Axis.horizontal), + Text( + '${speedUnits.fromMetersPerSecond(_speed).round()} ${speedUnits.acronym}', + style: Theme.of(context).textTheme.displayLarge, + ), + ObserverBuilder( + observable: showMarginOfErrorObservable, + builder: (context, showMarginOfError, _) => + showMarginOfError == true + ? Text( + '± ${speedUnits.fromMetersPerSecond(_speedAccuracy).round()} ${speedUnits.acronym}', + style: Theme.of(context).textTheme.displaySmall, + ) + : SizedBox(), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/jotai.dart b/lib/jotai.dart new file mode 100644 index 0000000..8e97fe8 --- /dev/null +++ b/lib/jotai.dart @@ -0,0 +1,190 @@ +// When I started this project, I knew I would eventually need either a library +// similar to streaming_shared_preferences, or a global state management system +// to cache the shared preferences. But I could not get that library to work, +// and I don't like any of the state management solutions that exist for +// Flutter. +// +// What I really wanted was something like jotai, and eventually I decided to +// just write my own. I'm very happy with how simple it is. It's just an +// Observable class, and a widget that listens for changes to the observable. +// It was very easy to extend it to save the preferences, and it's blazingly +// fast. +// +// There is a library called fl_observable that does essentially this but +// better. I didn't find it until after I finished the settings page, and it +// hasn't been updated for over a year. It also has very few users. + +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:speedometer/main.dart'; + +String _defaultToString(dynamic value) => value.toString(); + +void Function(T) storageObserver<T>( + String key, [ + String Function(T) toString = _defaultToString, +]) => + // Loading the shared preferences for asll of these might not be + // particularly efficient, but it seems fine on my Moto G Stylus. + (T value) => SharedPreferencesAsync().setString(key, toString(value)); + +Future<T> fromString<T>( + String key, + List<T> options, [ + String Function(T) toString = _defaultToString, +]) { + return SharedPreferencesAsync() + // This takes time to load in, but I've never noticed the delay + .getString(key) + .then( + // Going through all of these sounds like it's slower than necessary, + // but it's very simple, and most of the options lists are very small. + (value) { + try { + return options.firstWhere((option) => value == toString(option)); + } catch (error, stack) { + logObservable.value = logObservable.value + ..add('While loading $key:\n$error\n$stack'); + rethrow; + } + }, + ); +} + +Observable<T> observablePreference<T>( + String key, + T defaultValue, + List<T> options, [ + String Function(T) toString = _defaultToString, +]) => Observable.fromFuture( + initialValue: defaultValue, + future: fromString(key, options, toString), + observers: [storageObserver(key, toString)], +); + +final speedUnitsObservable = observablePreference('speedUnits', null, [ + ...SpeedUnit.values, + null, +]); +final themeModeObservable = observablePreference( + 'themeMode', + ThemeMode.system, + ThemeMode.values, +); +final primaryColorObservable = observablePreference( + "primaryColor", + Colors.red, + [...Colors.primaries, Colors.grey], + (color) => color.toARGB32().toString(), +); +final showMarginOfErrorObservable = observablePreference( + "showMarginOfError", + true, + [true, false], +); +final locationAccuracyObservable = observablePreference( + "locationAccuracy", + LocationAccuracy.best, + LocationAccuracy.values, +); + +final logObservable = Observable(<String>[]); +void log(String message) { + logObservable.value = logObservable.value..add(message); +} + +class Observable<T> { + T _value; + int _nextId = 0; + + // A map is used instead of a list for fast deletions and to avoid memory + // leaks. Storing IDs in a list would make deletion slow, and setting entries + // in the list to null would cause memory leaks. + final Map<int, void Function(T)> _observers = {}; + + Observable(this._value, {List<void Function(T)>? observers}) { + if (observers != null) { + for (var observer in observers) { + subscribe(observer); + } + } + } + + factory Observable.fromFuture({ + required T initialValue, + required Future<T> future, + List<void Function(T)>? observers, + }) { + final self = Observable(initialValue, observers: observers); + future.then((value) => self.value = value); + return self; + } + + T get value => _value; + set value(T value) { + if (value == _value) { + return; + } + + _value = value; + + for (var observer in _observers.values) { + observer(_value); + } + } + + int subscribe(void Function(T) onChange) { + final id = _nextId++; + _observers[id] = onChange; + return id; + } + + void unsubscribe(int subscriberId) { + _observers.remove(subscriberId); + } +} + +class ObserverBuilder<T> extends StatefulWidget { + final Observable<T> observable; + final Widget Function(BuildContext, T, void Function(T)) builder; + + const ObserverBuilder({ + required this.observable, + required this.builder, + super.key, + }); + + @override + State<ObserverBuilder<T>> createState() => _ObserverState<T>(); +} + +class _ObserverState<T> extends State<ObserverBuilder<T>> { + late T _value; + late int _subscriberId; + + @override + void initState() { + super.initState(); + _subscriberId = widget.observable.subscribe( + (value) => setState(() => _value = value), + ); + _value = widget.observable.value; + } + + @override + void dispose() { + super.dispose(); + widget.observable.unsubscribe(_subscriberId); + } + + @override + Widget build(BuildContext context) { + return widget.builder( + context, + _value, + (value) => widget.observable.value = value, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100755 index 0000000..403427c --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart';
+
+import 'home.dart';
+import 'jotai.dart';
+
+enum SpeedUnit { milesPerHour, kilometersPerHour }
+
+void main() async {
+ runApp(const MyApp());
+}
+
+class MyApp extends StatelessWidget {
+ const MyApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return ObserverBuilder(
+ observable: themeModeObservable,
+ builder: (context, themeMode, _) => ObserverBuilder(
+ observable: primaryColorObservable,
+ builder: (context, primaryColor, _) => MaterialApp(
+ title: 'Simple Speedometer',
+ theme: ThemeData(
+ colorScheme: ColorScheme.fromSeed(seedColor: primaryColor),
+ ),
+ darkTheme: ThemeData(
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: primaryColor,
+ brightness: Brightness.dark,
+ ),
+ ),
+ themeMode: themeMode,
+ home: HomePage(),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/settings.dart b/lib/settings.dart new file mode 100644 index 0000000..997e1a9 --- /dev/null +++ b/lib/settings.dart @@ -0,0 +1,297 @@ +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:speedometer/jotai.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import 'main.dart'; + +// Unfortunately, these need to be updated manually when a change is made to +// the version number or license. Storing these in code prevents the need for +// any loading. +const applicationName = "Simple Speedometer"; +const applicationVersion = "1.0.0"; +const applicationLegalese = r""" +Copyright (C) 2026 by Mica White <botahamec@outlook.com> + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +"""; + +class Option<T> { + final String text; + final T value; + + const Option({required this.text, required this.value}); +} + +// I had to do some of the design work here myself, and this seems pretty close +// to what other Material apps do on Android. I tried the settings_ui package, +// but it doesn't respect the color scheme that I am using. +class SectionHeader extends StatelessWidget { + final String title; + + const SectionHeader(this.title, {super.key}); + + @override + Widget build(BuildContext context) { + return ListTile( + titleTextStyle: TextStyle(color: Theme.of(context).colorScheme.primary), + title: Text(title), + ); + } +} + +// Despite being part of the Material spec, the material package in Flutter +// does not have an alert widget to emulate this behavior. +class SelectAlert<T> extends StatefulWidget { + final String title; + final T initialValue; + final List<Option<T>> options; + final void Function(T value) onChanged; + + const SelectAlert({ + required this.title, + required this.initialValue, + required this.options, + required this.onChanged, + super.key, + }); + + @override + State<SelectAlert<T>> createState() => _SelectAlertState<T>(); +} + +class _SelectAlertState<T> extends State<SelectAlert<T>> { + late T _currentValue; + + _SelectAlertState(); + + @override + void initState() { + super.initState(); + _currentValue = widget.initialValue; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Divider(), + Flexible( + child: SingleChildScrollView( + child: RadioGroup<T>( + groupValue: _currentValue, + onChanged: (value) { + widget.onChanged(value as T); + setState(() => _currentValue = value); + }, + child: Column( + children: widget.options + .map( + (option) => RadioListTile( + title: Text(option.text), + value: option.value, + ), + ) + .toList(), + ), + ), + ), + ), + Divider(), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Confirm'), + ), + ], + ); + } +} + +class SelectTile<T> extends StatelessWidget { + final String title; + final T value; + final List<Option<T>> options; + final void Function(T value) onChanged; + + const SelectTile({ + required this.title, + required this.value, + required this.options, + required this.onChanged, + super.key, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(title), + subtitle: Text( + options.firstWhere((option) => option.value == value).text, + ), + onTap: () => showDialog( + context: context, + builder: (context) => SelectAlert( + title: title, + initialValue: value, + options: options, + onChanged: onChanged, + ), + ), + ); + } +} + +class LogPage extends StatelessWidget { + const LogPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Logs')), + body: ObserverBuilder( + observable: logObservable, + builder: (context, logs, _) => ListView.builder( + itemCount: logs.length, + itemBuilder: (context, index) => + Text(logs[index], style: TextStyle(fontFamily: 'RobotoMono')), + ), + ), + ); + } +} + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Settings')), + body: ListView( + children: [ + SectionHeader('Units'), + ObserverBuilder( + observable: speedUnitsObservable, + builder: (context, speedUnits, setSpeedUnits) => SelectTile( + title: 'Units', + value: speedUnits, + options: [ + Option(text: 'Automatic', value: null), + Option(text: 'Kilometers', value: SpeedUnit.kilometersPerHour), + Option(text: 'Miles', value: SpeedUnit.milesPerHour), + ], + onChanged: (value) => setSpeedUnits(value), + ), + ), + Divider(), + SectionHeader('Appearance'), + ObserverBuilder( + observable: themeModeObservable, + builder: (context, themeMode, setThemeMode) => SelectTile( + title: 'Theme', + value: themeMode, + options: [ + Option(text: 'System', value: ThemeMode.system), + Option(text: 'Light', value: ThemeMode.light), + Option(text: 'Dark', value: ThemeMode.dark), + ], + onChanged: (value) => setThemeMode(value), + ), + ), + ObserverBuilder( + observable: primaryColorObservable, + builder: (context, primaryColor, setPrimaryColor) => SelectTile( + title: 'Primary color', + value: primaryColor, + options: [ + Option(text: 'Red', value: Colors.red), + Option(text: 'Pink', value: Colors.pink), + Option(text: 'Indigo', value: Colors.indigo), + Option(text: 'Purple', value: Colors.purple), + Option(text: 'Deep purple', value: Colors.deepPurple), + Option(text: 'Blue', value: Colors.blue), + Option(text: 'Light blue', value: Colors.lightBlue), + Option(text: 'Cyan', value: Colors.cyan), + Option(text: 'Teal', value: Colors.teal), + Option(text: 'Green', value: Colors.green), + Option(text: 'Light green', value: Colors.lightGreen), + Option(text: 'Lime', value: Colors.lime), + Option(text: 'Yellow', value: Colors.yellow), + Option(text: 'Amber', value: Colors.amber), + Option(text: 'Orange', value: Colors.orange), + Option(text: 'Deep orange', value: Colors.deepOrange), + Option(text: 'Brown', value: Colors.brown), + Option(text: 'Gray', value: Colors.grey), + Option(text: 'Blue gray', value: Colors.blueGrey), + ], + onChanged: (value) => setPrimaryColor(value), + ), + ), + ObserverBuilder( + observable: showMarginOfErrorObservable, + builder: (context, showMargingOfError, setShowMarginOfError) => + SwitchListTile( + title: Text('Show margin of error'), + value: showMargingOfError, + onChanged: (value) => + setShowMarginOfError(!showMargingOfError), + ), + ), + Divider(), + SectionHeader('Performance'), + ObserverBuilder( + observable: locationAccuracyObservable, + builder: (context, locationAccuracy, setLocationAccuracy) => + SelectTile( + title: 'Accuracy', + value: locationAccuracy, + options: [ + Option(text: 'Best', value: LocationAccuracy.best), + Option(text: 'High', value: LocationAccuracy.high), + Option(text: 'Medium', value: LocationAccuracy.medium), + Option(text: 'Low', value: LocationAccuracy.low), + Option(text: 'Lowest', value: LocationAccuracy.lowest), + ], + onChanged: (value) => setLocationAccuracy(value), + ), + ), + ListTile( + title: Text("View logs"), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => LogPage()), + ), + ), + Divider(), + SectionHeader('About'), + ListTile( + title: Text('About Simple Speedometer'), + onTap: () => showAdaptiveAboutDialog( + context: context, + applicationName: applicationName, + applicationVersion: applicationVersion, + applicationLegalese: applicationLegalese, + applicationIcon: Icon( + Icons.speed, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ListTile( + title: Text('View source code'), + onTap: () => + launchUrlString('https://www.botahamec.dev/cgit/speedometer'), + ), + ], + ), + ); + } +} |
