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