Files
rippr/lib/src/config/config.dart
uhryniuk 37fba4784a 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>
2026-08-17 14:23:43 -05:00

96 lines
3.3 KiB
Dart

/// Ported from `com.rippr.Config`.
///
/// Runtime configuration. The upload endpoint is intentionally empty by default —
/// 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);
final SharedPreferences _prefs;
static Future<Config> load() async =>
Config(await SharedPreferences.getInstance());
String get uploadEndpoint => _prefs.getString(_keyEndpoint) ?? '';
Future<void> setUploadEndpoint(String url) =>
_prefs.setString(_keyEndpoint, url.trim());
/// Whether trip detail renders a map at all.
///
/// When off the map widget is never created, so no tile is ever requested — a real
/// short-circuit, not a hidden view. The map only ever exists inside a visible screen's
/// lifecycle; the recorder never touches it.
bool get mapEnabled => _prefs.getBool(_keyMapEnabled) ?? true;
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);
if (existing != null) return existing;
final generated = _randomId();
// Fire-and-forget: the value is returned immediately either way, and a lost write
// only costs a new id next launch.
_prefs.setString(_keyDeviceId, generated);
return generated;
}
/// A UUID v4, without pulling in a package for sixteen bytes.
static String _randomId() {
final rng = Random.secure();
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
String hex(int start, int end) => bytes
.sublist(start, end)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
return '${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}';
}
}