From 359d8e07ef5cb585fff13031d075d7c949135317 Mon Sep 17 00:00:00 2001 From: Mica White Date: Tue, 13 Jan 2026 22:51:59 -0500 Subject: Complete settings page --- lib/about.dart | 85 ------------------ lib/home.dart | 90 +++++++++++-------- lib/jotai.dart | 150 +++++++++++++++++++++++++++++++ lib/main.dart | 28 +++--- lib/settings.dart | 263 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 484 insertions(+), 132 deletions(-) delete mode 100644 lib/about.dart create mode 100644 lib/jotai.dart create mode 100644 lib/settings.dart (limited to 'lib') diff --git a/lib/about.dart b/lib/about.dart deleted file mode 100644 index 7dbaaf4..0000000 --- a/lib/about.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:build_info/build_info.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -class AboutPage extends StatefulWidget { - const AboutPage({super.key}); - - @override - State createState() => _AboutPageState(); -} - -class _AboutPageState extends State { - Future<(PackageInfo, String, BuildInfoData?)>? _loadedData; - - @override - void initState() { - super.initState(); - _loadedData = Future(() async { - var packageInfo = await PackageInfo.fromPlatform(); - var license = await rootBundle.loadString('LICENSE'); - var buildInfo = await BuildInfo.fromPlatform(); - return (packageInfo, license, buildInfo); - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text('About')), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - Icons.speed, - size: Theme.of(context).textTheme.displayLarge?.fontSize ?? 72, - ), - Text( - 'Simple Speedometer', - style: Theme.of(context).textTheme.titleLarge, - ), - Text('© 2025 Mica White'), - Text(''), - TextButton.icon( - icon: Icon(Icons.code), - label: Text('View source code'), - onPressed: () => - launchUrlString("https://www.botahamec.dev/cgit/speedometer"), - ), - FutureBuilder( - future: _loadedData, - builder: (context, snapshot) { - final appName = snapshot.data?.$1.appName; - final version = snapshot.data?.$1.version; - final legalese = snapshot.data?.$2; - final buildDate = snapshot.data?.$3?.buildDate; - - return TextButton.icon( - icon: Icon(Icons.copyright), - label: Text('View licenses'), - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => LicensePage( - applicationIcon: Icon(Icons.speed), - applicationName: appName, - applicationVersion: snapshot.hasData - ? '$version (${buildDate?.year}-${buildDate?.month}-${buildDate?.day})' - : null, - applicationLegalese: legalese, - ), - ), - ), - ); - }, - ), - ], - ), - ), - ); - } -} diff --git a/lib/home.dart b/lib/home.dart index 371a52e..f141d0b 100644 --- a/lib/home.dart +++ b/lib/home.dart @@ -2,9 +2,11 @@ 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 'about.dart'; +import 'settings.dart'; extension on SpeedUnit { double fromMetersPerSecond(double metersPerSecond) => switch (this) { @@ -27,15 +29,15 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { StreamSubscription? _positionStream; + + bool _usesMetric = true; double _speed = 0.0; double _speedAccuracy = 0.0; - SpeedUnit _speedUnit = SpeedUnit.milesPerHour; - LocationSettings _locationSettings = LocationSettings(); - void _initPositionStream() { + void _initPositionStream(LocationAccuracy locationAccuracy) { _positionStream = Geolocator.getPositionStream( - locationSettings: _locationSettings, + locationSettings: LocationSettings(accuracy: locationAccuracy), ).listen((Position? position) { if (position != null) { setState(() { @@ -61,9 +63,18 @@ class _HomePageState extends State { LocationPermission.deniedForever, LocationPermission.denied, ].contains(permission)) { - _initPositionStream(); + _initPositionStream(locationAccuracyObservable.value); } }); + + locationAccuracyObservable.subscribe((locationAccuracy) { + _positionStream?.cancel(); + _initPositionStream(locationAccuracy); + }); + + RegionSettings.getUsesMetricSystem().then( + (usesMetricSystem) => setState(() => _usesMetric = usesMetricSystem), + ); } @override @@ -77,41 +88,46 @@ class _HomePageState extends State { return Scaffold( appBar: AppBar( actions: [ - MenuAnchor( - builder: (context, controller, child) => TextButton.icon( - icon: Icon(Icons.settings), - label: Text('Settings'), - onPressed: () => - controller.isOpen ? controller.close() : controller.open(), - ), - menuChildren: [ - MenuItemButton(child: Text('Settings')), - MenuItemButton( - child: Text('About'), - onPressed: () => Navigator.push( - context, - MaterialPageRoute(builder: (context) => AboutPage()), - ), - ), - ], + TextButton.icon( + icon: Icon(Icons.settings), + label: Text('Settings'), + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (context) => SettingsPage())), ), ], ), body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Flex(direction: Axis.horizontal), - Text( - '${_speedUnit.fromMetersPerSecond(_speed).round()} ${_speedUnit.acronym}', - style: Theme.of(context).textTheme.displayLarge, - ), - Text( - '± ${_speedUnit.fromMetersPerSecond(_speedAccuracy).round()} ${_speedUnit.acronym}', - style: Theme.of(context).textTheme.displaySmall, - ), - ], + 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..a415e13 --- /dev/null +++ b/lib/jotai.dart @@ -0,0 +1,150 @@ +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( + String key, [ + String Function(T) toString = _defaultToString, +]) { + return (T value) => SharedPreferencesAsync().setString(key, toString(value)); +} + +Future fromString( + String key, + List options, [ + String Function(T) toString = _defaultToString, +]) { + return SharedPreferencesAsync() + .getString(key) + .then( + (value) => options.firstWhere((option) => value == toString(option)), + ); +} + +Observable observablePreference( + String key, + T defaultValue, + List options, [ + String Function(T) toString = _defaultToString, +]) => Observable( + defaultValue, + loadValue: 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, +); + +class Observable { + T _value; + final List _observers = []; + + Observable( + this._value, { + Future? loadValue, + List? observers, + }) { + if (observers != null) { + _observers.addAll(observers); + } + + if (loadValue != null) { + loadValue.then((value) => this.value = value); + } + } + + T get value => _value; + set value(T value) { + if (value == _value) { + return; + } + + _value = value; + + for (var observer in _observers) { + if (observer != null) { + observer(_value); + } + } + } + + int subscribe(void Function(T) onChange) { + final id = _observers.length; + _observers.add(onChange); + return id; + } + + void unsubscribe(int subscriberId) { + _observers[subscriberId] = null; + } +} + +class ObserverBuilder extends StatefulWidget { + final Observable observable; + final Widget Function(BuildContext, T, void Function(T)) builder; + + const ObserverBuilder({ + required this.observable, + required this.builder, + super.key, + }); + + @override + State> createState() => _ObserverState(); +} + +class _ObserverState extends State> { + 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 index 1bfbccc..403427c 100755 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'home.dart'; +import 'jotai.dart'; enum SpeedUnit { milesPerHour, kilometersPerHour } @@ -13,18 +14,25 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Speedometer', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.red), - ), - darkTheme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.red, - brightness: Brightness.dark, + 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(), ), ), - home: HomePage(), ); } } diff --git a/lib/settings.dart b/lib/settings.dart new file mode 100644 index 0000000..baaf0f4 --- /dev/null +++ b/lib/settings.dart @@ -0,0 +1,263 @@ +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'; + +const applicationName = "Simple Speedometer"; +const applicationVersion = "1.0.0"; +const applicationLegalese = r""" +Copyright (C) 2025 by Mica White + +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 { + final String text; + final T value; + + const Option({required this.text, required this.value}); +} + +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), + ); + } +} + +class SelectAlert extends StatefulWidget { + final String title; + final T initialValue; + final List> 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> createState() => _SelectAlertState(); +} + +class _SelectAlertState extends State> { + 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( + 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 extends StatelessWidget { + final String title; + final T value; + final List> 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 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), + ), + ), + 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'), + ), + ], + ), + ); + } +} -- cgit v1.2.3