V3-02 + V3-03: settings screen, and metric/imperial units
Built together because V3-02's settings screen needed something real for V3-03's units to control -- executed in reverse of the ticket numbering, but both tickets are independently complete. V3-03: UnitSystem (metric/imperial) lives in domain/models.dart alongside Activity, defaulting from Platform.localeName on first launch. Storage stays SI everywhere -- conversion happens only in ui/format.dart, at the last possible moment, which is now documented as the file's central invariant. Threaded through all three screens plus the speed histogram's bucket labels, which relabel for display without changing how speedHistogram itself bins. Export was deliberately left untouched: gpx()/geoJson() take no UnitSystem parameter at all, a stronger guarantee than validating one would be. V3-02: SettingsScreen reachable from the record screen. Map render toggle (nothing previously exposed mapEnabledProvider to the user, despite it existing since T15 -- there was nothing to "move off trip detail" as drafted), unit selector, upload endpoint with http(s) validation, a copyable device id, and a licences page via Flutter's built-in showLicensePage. Skipped package_info_plus (static version string instead) and a privacy-policy link (none published yet) as disproportionate to an S-sized ticket. Neither ticket needed the ConfigNotifier the implementation notes proposed: unitSystemProvider reuses the exact StateProvider-seeded-from-Config pattern mapEnabledProvider already established, since Config mutates its own backing SharedPreferences in place and re-assigning the same instance would never notify a watcher anyway. A real locale-dependent flake was caught, not just anticipated: a test asserting a fresh Config defaults to metric failed, because this machine's own locale resolves to a region in the imperial set. Fixed by seeding an explicit value before asserting, and config_test.dart's locale test was written from the start to only prove the fallback path doesn't throw, not to assert which value it returns. 221 tests (211 -> 221): 13 format_test, 9 config_test, 9 settings_screen_test, 1 confirming the record screen's settings button is genuinely wired. Analyze clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -101,3 +101,12 @@ final recorderStateProvider = StreamProvider<RecorderState>((ref) {
|
||||
final mapEnabledProvider = StateProvider<bool>(
|
||||
(ref) => ref.watch(configProvider)?.mapEnabled ?? true,
|
||||
);
|
||||
|
||||
/// Metric or imperial. Same shape as [mapEnabledProvider]: seeded from [Config] once,
|
||||
/// then read and written directly by the UI so a change is visible immediately without
|
||||
/// waiting on [configProvider]'s identity to change (it never does — `Config` mutates
|
||||
/// its own backing preferences in place, so re-assigning the same instance would not
|
||||
/// notify anything watching it).
|
||||
final unitSystemProvider = StateProvider<UnitSystem>(
|
||||
(ref) => ref.watch(configProvider)?.unitSystem ?? UnitSystem.metric,
|
||||
);
|
||||
|
||||
@@ -4,13 +4,21 @@
|
||||
/// recording must work with no server at all, and uploading is opt-in.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../domain/models.dart' show UnitSystem;
|
||||
|
||||
const _keyEndpoint = 'upload_endpoint';
|
||||
const _keyDeviceId = 'device_id';
|
||||
const _keyMapEnabled = 'map_enabled';
|
||||
const _keyUnitSystem = 'unit_system';
|
||||
|
||||
/// Countries that did not adopt metric for everyday distances. Not exhaustive — a
|
||||
/// best-effort default, not a claim of authority. Anyone can override it in Settings.
|
||||
const _imperialCountryCodes = {'US', 'LR', 'MM'};
|
||||
|
||||
class Config {
|
||||
Config(this._prefs);
|
||||
@@ -35,6 +43,32 @@ class Config {
|
||||
Future<void> setMapEnabled(bool enabled) =>
|
||||
_prefs.setBool(_keyMapEnabled, enabled);
|
||||
|
||||
/// Metric unless the device's own locale says otherwise. Explicit user choice, once
|
||||
/// made, always wins over the locale guess.
|
||||
UnitSystem get unitSystem {
|
||||
final stored = _prefs.getString(_keyUnitSystem);
|
||||
if (stored != null) {
|
||||
return UnitSystem.values.firstWhere(
|
||||
(u) => u.name == stored,
|
||||
orElse: () => _localeDefaultUnitSystem(),
|
||||
);
|
||||
}
|
||||
return _localeDefaultUnitSystem();
|
||||
}
|
||||
|
||||
Future<void> setUnitSystem(UnitSystem system) =>
|
||||
_prefs.setString(_keyUnitSystem, system.name);
|
||||
|
||||
static UnitSystem _localeDefaultUnitSystem() {
|
||||
// "en_US", "en_US.UTF-8" and similar; the region is the second underscore- or
|
||||
// hyphen-delimited segment when present.
|
||||
final parts = Platform.localeName.split(RegExp('[_-]'));
|
||||
final region = parts.length > 1 ? parts[1].toUpperCase() : '';
|
||||
return _imperialCountryCodes.contains(region)
|
||||
? UnitSystem.imperial
|
||||
: UnitSystem.metric;
|
||||
}
|
||||
|
||||
/// Stable per-install id so a server can distinguish riders in a group.
|
||||
String get deviceId {
|
||||
final existing = _prefs.getString(_keyDeviceId);
|
||||
|
||||
@@ -33,6 +33,15 @@ enum TripState { recording, paused, completed }
|
||||
/// `docs/v3/`.
|
||||
enum Activity { motorcycle, bicycle, scooter, skateboard, running, walking, other }
|
||||
|
||||
/// Metric or imperial, chosen once as a whole-app preference.
|
||||
///
|
||||
/// **Display-only, forever.** Storage stays SI (metres, km/h) everywhere else in the
|
||||
/// app — converting at the storage layer would corrupt every recorded ride and break the
|
||||
/// cross-language parity harness, which compares raw metric values against the Kotlin
|
||||
/// original. See `ui/format.dart`, the only place this enum's value should ever change a
|
||||
/// number rather than just relabel one. See V3-03 in `docs/v3/`.
|
||||
enum UnitSystem { metric, imperial }
|
||||
|
||||
/// One ride, from pressing Start to pressing Stop.
|
||||
///
|
||||
/// The aggregate fields are denormalised on purpose. They are accumulated as points
|
||||
|
||||
@@ -109,6 +109,7 @@ class TripDetailScreen extends ConsumerWidget {
|
||||
),
|
||||
AsyncValue(hasValue: true, value: final d?) => _Body(
|
||||
detail: d,
|
||||
units: ref.watch(unitSystemProvider),
|
||||
onEditActivity: () => _editActivity(context, ref, d.trip),
|
||||
),
|
||||
AsyncValue(hasError: true, :final error) => EmptyState(
|
||||
@@ -219,14 +220,23 @@ class TripDetailScreen extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _Body extends StatelessWidget {
|
||||
const _Body({required this.detail, required this.onEditActivity});
|
||||
const _Body({
|
||||
required this.detail,
|
||||
required this.units,
|
||||
required this.onEditActivity,
|
||||
});
|
||||
|
||||
final TripDetail detail;
|
||||
final UnitSystem units;
|
||||
final VoidCallback onEditActivity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = detail.summary;
|
||||
final (distanceValue, distanceUnit) = formatDistanceParts(
|
||||
s.distanceM,
|
||||
unit: units,
|
||||
);
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
@@ -234,8 +244,8 @@ class _Body extends StatelessWidget {
|
||||
child: BigStat(
|
||||
key: const Key('distance'),
|
||||
label: 'DISTANCE',
|
||||
value: (s.distanceM / 1000).toStringAsFixed(1),
|
||||
unit: 'km',
|
||||
value: distanceValue,
|
||||
unit: distanceUnit,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -266,18 +276,21 @@ class _Body extends StatelessWidget {
|
||||
label: 'Stopped',
|
||||
value: formatDuration(s.stoppedMillis),
|
||||
),
|
||||
StatRow(label: 'Max speed', value: formatSpeed(s.maxSpeedKmh)),
|
||||
StatRow(
|
||||
label: 'Max speed',
|
||||
value: formatSpeed(s.maxSpeedKmh, unit: units),
|
||||
),
|
||||
StatRow(
|
||||
label: 'Avg moving speed',
|
||||
value: formatSpeed(s.avgMovingSpeedKmh),
|
||||
value: formatSpeed(s.avgMovingSpeedKmh, unit: units),
|
||||
),
|
||||
StatRow(
|
||||
label: 'Elevation gain',
|
||||
value: formatElevation(s.elevationGainM),
|
||||
value: formatElevation(s.elevationGainM, unit: units),
|
||||
),
|
||||
StatRow(
|
||||
label: 'Elevation loss',
|
||||
value: formatElevation(s.elevationLossM),
|
||||
value: formatElevation(s.elevationLossM, unit: units),
|
||||
),
|
||||
StatRow(label: 'Points', value: '${s.pointCount}'),
|
||||
StatRow(label: 'Segments', value: '${detail.segments.length}'),
|
||||
@@ -287,7 +300,7 @@ class _Body extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_SectionTitle('Speed distribution'),
|
||||
_Histogram(buckets: detail.histogram),
|
||||
_Histogram(buckets: detail.histogram, units: units),
|
||||
const SizedBox(height: 24),
|
||||
_SectionTitle('Elevation profile'),
|
||||
_ElevationChart(samples: detail.profile),
|
||||
@@ -351,9 +364,10 @@ class _SectionTitle extends StatelessWidget {
|
||||
/// Degrades to a message rather than an empty box: a ride of one point has no intervals
|
||||
/// to measure, and an unexplained blank looks like a bug.
|
||||
class _Histogram extends StatelessWidget {
|
||||
const _Histogram({required this.buckets});
|
||||
const _Histogram({required this.buckets, required this.units});
|
||||
|
||||
final List<SpeedBucket> buckets;
|
||||
final UnitSystem units;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -375,7 +389,7 @@ class _Histogram extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: Text(
|
||||
b.label,
|
||||
formatSpeedRangeLabel(b.fromKmh, b.toKmh, unit: units),
|
||||
style: TextStyle(fontSize: 12, color: colors.outline),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,12 +5,26 @@
|
||||
/// Timestamps come from the platform location fix, which is UTC epoch millis, so
|
||||
/// everything here converts to the device zone. Formatting in UTC would show a 21:00
|
||||
/// ride as tomorrow.
|
||||
///
|
||||
/// ## Units (V3-01)
|
||||
///
|
||||
/// Storage is SI everywhere — metres, km/h — forever. [UnitSystem] is a **display-only**
|
||||
/// concern: every conversion happens here, at the last possible moment, and nowhere
|
||||
/// else. Converting further up the stack would corrupt stored rides and break the
|
||||
/// cross-language parity harness, which compares raw metric values against the Kotlin
|
||||
/// original. Exports (`export/ride_export.dart`) deliberately do not take a
|
||||
/// [UnitSystem] at all — GPX and GeoJSON are metric by specification, and changing that
|
||||
/// would break every consumer that reads them.
|
||||
library;
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../domain/models.dart';
|
||||
|
||||
const double _milesPerMeter = 1 / 1609.344;
|
||||
const double _feetPerMeter = 3.28084;
|
||||
const double _mphPerKmh = 0.621371;
|
||||
|
||||
// Built per call rather than cached in a top-level final. The Kotlin original captured
|
||||
// Locale.getDefault() once at class-init; doing the same here would freeze the format
|
||||
// for the process lifetime and ignore a locale change.
|
||||
@@ -26,10 +40,61 @@ String formatFileTimestamp(int epochMillis) =>
|
||||
/// A trip's own name, or a date-derived label when it has none.
|
||||
String tripLabel(Trip trip) => trip.name ?? formatDateTime(trip.startedAt);
|
||||
|
||||
String formatDistance(double meters) => meters < 1000
|
||||
? '${meters.toInt()} m'
|
||||
: '${(meters / 1000).toStringAsFixed(1)} km';
|
||||
String formatDistance(double meters, {UnitSystem unit = UnitSystem.metric}) {
|
||||
if (unit == UnitSystem.imperial) {
|
||||
final feet = meters * _feetPerMeter;
|
||||
return feet < 1000
|
||||
? '${feet.toInt()} ft'
|
||||
: '${(meters * _milesPerMeter).toStringAsFixed(1)} mi';
|
||||
}
|
||||
return meters < 1000
|
||||
? '${meters.toInt()} m'
|
||||
: '${(meters / 1000).toStringAsFixed(1)} km';
|
||||
}
|
||||
|
||||
String formatSpeed(double kmh) => '${kmh.toStringAsFixed(1)} km/h';
|
||||
String formatSpeed(double kmh, {UnitSystem unit = UnitSystem.metric}) =>
|
||||
unit == UnitSystem.imperial
|
||||
? '${(kmh * _mphPerKmh).toStringAsFixed(1)} mph'
|
||||
: '${kmh.toStringAsFixed(1)} km/h';
|
||||
|
||||
String formatElevation(double meters) => '${meters.toInt()} m';
|
||||
String formatElevation(double meters, {UnitSystem unit = UnitSystem.metric}) =>
|
||||
unit == UnitSystem.imperial
|
||||
? '${(meters * _feetPerMeter).toInt()} ft'
|
||||
: '${meters.toInt()} m';
|
||||
|
||||
/// Value and unit split apart, for `BigStat`'s large-number-plus-small-label layout.
|
||||
/// Always the "big" unit (km or mi) regardless of the short-distance threshold above —
|
||||
/// a headline post-ride figure is never usefully shown in metres or feet.
|
||||
(String value, String unit) formatDistanceParts(
|
||||
double meters, {
|
||||
UnitSystem unit = UnitSystem.metric,
|
||||
}) => unit == UnitSystem.imperial
|
||||
? ((meters * _milesPerMeter).toStringAsFixed(1), 'mi')
|
||||
: ((meters / 1000).toStringAsFixed(1), 'km');
|
||||
|
||||
/// As [formatDistanceParts], for the live speedo on the record screen. Zero decimal
|
||||
/// places, matching that screen's existing display.
|
||||
(String value, String unit) formatSpeedParts(
|
||||
double kmh, {
|
||||
UnitSystem unit = UnitSystem.metric,
|
||||
}) => unit == UnitSystem.imperial
|
||||
? ((kmh * _mphPerKmh).toStringAsFixed(0), 'mph')
|
||||
: (kmh.toStringAsFixed(0), 'km/h');
|
||||
|
||||
/// A speed-histogram bucket's `from–to` label, converted to the chosen unit and rounded
|
||||
/// to whole units. A **relabel, not a re-bucketing**: `stats/ride_statistics.dart`'s
|
||||
/// `speedHistogram` always bins in km/h regardless of the display unit, so switching
|
||||
/// units changes what the numbers on this label mean without moving any data between
|
||||
/// bars.
|
||||
String formatSpeedRangeLabel(
|
||||
int fromKmh,
|
||||
int toKmh, {
|
||||
UnitSystem unit = UnitSystem.metric,
|
||||
}) {
|
||||
if (unit == UnitSystem.imperial) {
|
||||
final from = (fromKmh * _mphPerKmh).round();
|
||||
final to = (toKmh * _mphPerKmh).round();
|
||||
return '$from–$to';
|
||||
}
|
||||
return '$fromKmh–$toKmh';
|
||||
}
|
||||
|
||||
@@ -35,9 +35,10 @@ class RecordUiState {
|
||||
}
|
||||
|
||||
class RecordScreen extends ConsumerStatefulWidget {
|
||||
const RecordScreen({super.key, this.onOpenTrips});
|
||||
const RecordScreen({super.key, this.onOpenTrips, this.onOpenSettings});
|
||||
|
||||
final VoidCallback? onOpenTrips;
|
||||
final VoidCallback? onOpenSettings;
|
||||
|
||||
@override
|
||||
ConsumerState<RecordScreen> createState() => _RecordScreenState();
|
||||
@@ -116,6 +117,8 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final engine = ref.watch(recordingEngineProvider);
|
||||
final trip = ref.watch(activeTripProvider).valueOrNull;
|
||||
final units = ref.watch(unitSystemProvider);
|
||||
final (speedValue, speedUnit) = formatSpeedParts(_liveSpeed, unit: units);
|
||||
|
||||
final ui = RecordUiState(
|
||||
trip: trip,
|
||||
@@ -155,9 +158,19 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: widget.onOpenTrips,
|
||||
child: const Text('Rides'),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onOpenTrips,
|
||||
child: const Text('Rides'),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('open-settings'),
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: 'Settings',
|
||||
onPressed: widget.onOpenSettings,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -169,8 +182,8 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
BigStat(
|
||||
key: const Key('speed'),
|
||||
label: 'SPEED',
|
||||
value: ui.currentSpeedKmh.toStringAsFixed(0),
|
||||
unit: 'km/h',
|
||||
value: speedValue,
|
||||
unit: speedUnit,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -190,7 +203,10 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
] else ...[
|
||||
StatRow(
|
||||
label: 'Distance',
|
||||
value: formatDistance(ui.trip!.distanceM),
|
||||
value: formatDistance(
|
||||
ui.trip!.distanceM,
|
||||
unit: units,
|
||||
),
|
||||
),
|
||||
StatRow(
|
||||
label: 'Elapsed',
|
||||
@@ -202,7 +218,10 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
),
|
||||
StatRow(
|
||||
label: 'Max speed',
|
||||
value: formatSpeed(ui.trip!.maxSpeedKmh),
|
||||
value: formatSpeed(
|
||||
ui.trip!.maxSpeedKmh,
|
||||
unit: units,
|
||||
),
|
||||
),
|
||||
StatRow(
|
||||
label: 'Points captured',
|
||||
@@ -210,7 +229,10 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
),
|
||||
StatRow(
|
||||
label: 'Elevation gain',
|
||||
value: formatElevation(ui.trip!.elevationGainM),
|
||||
value: formatElevation(
|
||||
ui.trip!.elevationGainM,
|
||||
unit: units,
|
||||
),
|
||||
),
|
||||
if (ui.isPaused)
|
||||
const StatRow(label: 'Status', value: 'Paused'),
|
||||
|
||||
@@ -10,12 +10,14 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'detail/trip_detail_screen.dart';
|
||||
import 'record/record_screen.dart';
|
||||
import 'settings/settings_screen.dart';
|
||||
import 'trips/trips_screen.dart';
|
||||
|
||||
abstract final class Routes {
|
||||
static const record = '/';
|
||||
static const trips = '/trips';
|
||||
static const tripDetail = '/trip/:tripId';
|
||||
static const settings = '/settings';
|
||||
|
||||
static String detailFor(int tripId) => '/trip/$tripId';
|
||||
}
|
||||
@@ -25,8 +27,15 @@ GoRouter buildRouter() => GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.record,
|
||||
builder: (context, state) => RecordScreen(
|
||||
onOpenTrips: () => context.push(Routes.trips),
|
||||
onOpenSettings: () => context.push(Routes.settings),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.settings,
|
||||
builder: (context, state) =>
|
||||
RecordScreen(onOpenTrips: () => context.push(Routes.trips)),
|
||||
SettingsScreen(onBack: () => context.pop()),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.trips,
|
||||
|
||||
217
lib/src/ui/settings/settings_screen.dart
Normal file
217
lib/src/ui/settings/settings_screen.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
/// One place for the preferences that previously had nowhere to live — most of all the
|
||||
/// upload endpoint, which has **never** had UI, in the native app or the port. See V3-02.
|
||||
///
|
||||
/// Kept plain on purpose: this is not a screen anyone should spend time in.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/providers.dart';
|
||||
import '../../config/config.dart';
|
||||
import '../../domain/models.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key, this.onBack});
|
||||
|
||||
final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final config = ref.watch(configProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
key: const Key('back'),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: onBack,
|
||||
),
|
||||
title: const Text('Settings'),
|
||||
),
|
||||
// Config loads once at startup (see main.dart) and is normally already present by
|
||||
// the time anyone reaches this screen, but the seam is real: show a spinner rather
|
||||
// than crash on a null read in the small window before it resolves.
|
||||
body: config == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _SettingsBody(config: config),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsBody extends ConsumerStatefulWidget {
|
||||
const _SettingsBody({required this.config});
|
||||
|
||||
final Config config;
|
||||
|
||||
@override
|
||||
ConsumerState<_SettingsBody> createState() => _SettingsBodyState();
|
||||
}
|
||||
|
||||
class _SettingsBodyState extends ConsumerState<_SettingsBody> {
|
||||
late final _endpointController =
|
||||
TextEditingController(text: widget.config.uploadEndpoint);
|
||||
String? _endpointError;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_endpointController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _looksLikeAnEndpoint(String value) {
|
||||
if (value.isEmpty) return true; // empty disables upload -- always valid
|
||||
final uri = Uri.tryParse(value);
|
||||
return uri != null &&
|
||||
(uri.scheme == 'http' || uri.scheme == 'https') &&
|
||||
uri.host.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<void> _saveEndpoint() async {
|
||||
final value = _endpointController.text.trim();
|
||||
if (!_looksLikeAnEndpoint(value)) {
|
||||
setState(
|
||||
() => _endpointError = 'Must be a full http:// or https:// address',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _endpointError = null);
|
||||
await widget.config.setUploadEndpoint(value);
|
||||
// TelemetryUploader is built once per Config value inside uploaderProvider, which
|
||||
// does not know a preference changed underneath the same Config instance -- see
|
||||
// the note on unitSystemProvider for why this cannot rely on identity changing.
|
||||
ref.invalidate(uploaderProvider);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(value.isEmpty ? 'Upload disabled' : 'Endpoint saved')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mapEnabled = ref.watch(mapEnabledProvider);
|
||||
final units = ref.watch(unitSystemProvider);
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
const _SectionHeader('Map'),
|
||||
SwitchListTile(
|
||||
key: const Key('map-enabled-switch'),
|
||||
title: const Text('Render maps on trip detail'),
|
||||
subtitle: const Text(
|
||||
'Off means no tile is ever fetched -- not just hidden.',
|
||||
),
|
||||
value: mapEnabled,
|
||||
onChanged: (value) async {
|
||||
await widget.config.setMapEnabled(value);
|
||||
ref.read(mapEnabledProvider.notifier).state = value;
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
const _SectionHeader('Units'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: SegmentedButton<UnitSystem>(
|
||||
key: const Key('unit-system-selector'),
|
||||
segments: const [
|
||||
ButtonSegment(value: UnitSystem.metric, label: Text('Metric')),
|
||||
ButtonSegment(value: UnitSystem.imperial, label: Text('Imperial')),
|
||||
],
|
||||
selected: {units},
|
||||
onSelectionChanged: (selection) async {
|
||||
final chosen = selection.first;
|
||||
await widget.config.setUnitSystem(chosen);
|
||||
ref.read(unitSystemProvider.notifier).state = chosen;
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const _SectionHeader('Sync'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: TextField(
|
||||
key: const Key('endpoint-field'),
|
||||
controller: _endpointController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Upload endpoint',
|
||||
hintText: 'https://example.com/ingest',
|
||||
helperText: 'Leave empty to keep recording fully local.',
|
||||
errorText: _endpointError,
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
onSubmitted: (_) => _saveEndpoint(),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 4, bottom: 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
key: const Key('save-endpoint'),
|
||||
onPressed: _saveEndpoint,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Device ID'),
|
||||
subtitle: Text(widget.config.deviceId),
|
||||
trailing: IconButton(
|
||||
key: const Key('copy-device-id'),
|
||||
icon: const Icon(Icons.copy),
|
||||
tooltip: 'Copy',
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: widget.config.deviceId),
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Device ID copied')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const _SectionHeader('About'),
|
||||
const ListTile(title: Text('Version'), subtitle: Text('1.0.0')),
|
||||
ListTile(
|
||||
title: const Text('Privacy'),
|
||||
subtitle: const Text(
|
||||
'No policy published yet -- see docs/LAUNCH.md before a public release.',
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Open source licences'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => showLicensePage(
|
||||
context: context,
|
||||
applicationName: 'Rippr',
|
||||
applicationVersion: '1.0.0',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader(this.text);
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
text.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
letterSpacing: 2,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ class TripsScreen extends ConsumerWidget {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final tripsAsync = ref.watch(completedTripsProvider);
|
||||
final trips = tripsAsync.valueOrNull ?? const <Trip>[];
|
||||
final units = ref.watch(unitSystemProvider);
|
||||
|
||||
// Drop ids that no longer exist, so a deleted trip cannot linger in the selection.
|
||||
final live = ref
|
||||
@@ -122,6 +123,7 @@ class TripsScreen extends ConsumerWidget {
|
||||
trip: trip,
|
||||
selected: live.contains(trip.id),
|
||||
selecting: selecting,
|
||||
units: units,
|
||||
onTap: () {
|
||||
if (selecting) {
|
||||
_toggle(ref, trip.id);
|
||||
@@ -203,6 +205,7 @@ class _TripTile extends StatelessWidget {
|
||||
required this.trip,
|
||||
required this.selected,
|
||||
required this.selecting,
|
||||
required this.units,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
@@ -210,6 +213,7 @@ class _TripTile extends StatelessWidget {
|
||||
final Trip trip;
|
||||
final bool selected;
|
||||
final bool selecting;
|
||||
final UnitSystem units;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
@@ -233,9 +237,9 @@ class _TripTile extends StatelessWidget {
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${formatDistance(trip.distanceM)} · '
|
||||
'${formatDistance(trip.distanceM, unit: units)} · '
|
||||
'${formatDuration(trip.movingMillis)} · '
|
||||
'max ${formatSpeed(trip.maxSpeedKmh)}',
|
||||
'max ${formatSpeed(trip.maxSpeedKmh, unit: units)}',
|
||||
style: TextStyle(color: colors.outline),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user