diff --git a/lib/app.dart b/lib/app.dart index 8183d9b..6e345e3 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:mileograph_flutter/services/endpoint_service.dart'; import 'package:mileograph_flutter/ui/app_shell.dart'; import 'package:provider/provider.dart'; @@ -12,8 +13,16 @@ class App extends StatelessWidget { Widget build(BuildContext context) { return MultiProvider( providers: [ - Provider( - create: (_) => ApiService(baseUrl: 'https://mileograph.co.uk/api/v1'), + ChangeNotifierProvider( + create: (_) => EndpointService(), + ), + ProxyProvider( + update: (_, endpoint, api) { + final service = api ?? ApiService(baseUrl: endpoint.baseUrl); + service.setBaseUrl(endpoint.baseUrl); + return service; + }, + create: (_) => ApiService(baseUrl: EndpointService.defaultBaseUrl), ), ChangeNotifierProvider( create: (context) => AuthService(api: context.read()), diff --git a/lib/components/dashboard/latest_loco_changes_panel.dart b/lib/components/dashboard/latest_loco_changes_panel.dart index 0e07b23..739f860 100644 --- a/lib/components/dashboard/latest_loco_changes_panel.dart +++ b/lib/components/dashboard/latest_loco_changes_panel.dart @@ -80,7 +80,8 @@ class _LatestLocoChangesPanelState extends State { child: ListView.separated( controller: _controller, itemCount: changes.length, - separatorBuilder: (_, __) => const Divider(height: 1), + separatorBuilder: (context, index) => + const Divider(height: 1), itemBuilder: (context, index) { final change = changes[index]; return ListTile( diff --git a/lib/components/legs/leg_card.dart b/lib/components/legs/leg_card.dart index 37bf897..c127366 100644 --- a/lib/components/legs/leg_card.dart +++ b/lib/components/legs/leg_card.dart @@ -140,7 +140,7 @@ class LegCard extends StatelessWidget { : textTheme.bodyMedium?.copyWith(color: theme.disabledColor); final background = powering ? theme.colorScheme.surfaceContainerHighest - : theme.colorScheme.surfaceVariant; + : theme.colorScheme.surfaceContainerLow; return Chip( label: Text( '${loco.locoClass} ${loco.number}', diff --git a/lib/components/login/login.dart b/lib/components/login/login.dart index fb91d89..5bef8c3 100644 --- a/lib/components/login/login.dart +++ b/lib/components/login/login.dart @@ -81,6 +81,12 @@ class _LoginScreenState extends State { ), const SizedBox(height: 50), const LoginPanel(), + const SizedBox(height: 16), + IconButton( + icon: const Icon(Icons.settings, color: Colors.grey), + tooltip: 'Settings', + onPressed: () => context.go('/settings'), + ), ], ), ), diff --git a/lib/components/pages/dashboard.dart b/lib/components/pages/dashboard.dart index 6d860d6..38cd424 100644 --- a/lib/components/pages/dashboard.dart +++ b/lib/components/pages/dashboard.dart @@ -348,14 +348,11 @@ class _DashboardState extends State { return Row( mainAxisSize: MainAxisSize.min, children: [ - ...buttons - .map( - (btn) => Padding( - padding: const EdgeInsets.only(left: 8.0), - child: btn, - ), - ) - .toList(), + for (final btn in buttons) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: btn, + ), ], ); } diff --git a/lib/components/pages/legs.dart b/lib/components/pages/legs.dart index fc151f4..b66d285 100644 --- a/lib/components/pages/legs.dart +++ b/lib/components/pages/legs.dart @@ -246,7 +246,8 @@ class _LegsPageState extends State { final dayLegs = []; void flushDay() { - if (currentDate == null) return; + final date = currentDate; + if (date == null) return; widgets.add( Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), @@ -254,7 +255,7 @@ class _LegsPageState extends State { children: [ Expanded( child: Text( - currentDate!, + date, style: Theme.of(context).textTheme.labelMedium?.copyWith( fontWeight: FontWeight.w700, ), diff --git a/lib/components/pages/loco_timeline/timeline_grid.dart b/lib/components/pages/loco_timeline/timeline_grid.dart index ad919af..8e54f46 100644 --- a/lib/components/pages/loco_timeline/timeline_grid.dart +++ b/lib/components/pages/loco_timeline/timeline_grid.dart @@ -445,7 +445,7 @@ class _ValueBlockMenu extends StatelessWidget { Future showContextMenuAt(Offset globalPosition) async { final overlay = Overlay.of(context); - final renderBox = overlay?.context.findRenderObject() as RenderBox?; + final renderBox = overlay.context.findRenderObject() as RenderBox?; if (renderBox == null) return; // Translate from global screen coordinates into the overlay's local space // so the menu appears where the gesture happened. diff --git a/lib/components/pages/settings.dart b/lib/components/pages/settings.dart new file mode 100644 index 0000000..8debed7 --- /dev/null +++ b/lib/components/pages/settings.dart @@ -0,0 +1,213 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:mileograph_flutter/services/endpoint_service.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:provider/provider.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + late final TextEditingController _endpointController; + bool _saving = false; + + @override + void initState() { + super.initState(); + final endpoint = context.read().baseUrl; + _endpointController = TextEditingController(text: endpoint); + } + + @override + void dispose() { + _endpointController.dispose(); + super.dispose(); + } + + Future _probeVersion(String url) async { + try { + var uri = Uri.parse(url.trim()); + if (uri.scheme.isEmpty) { + uri = Uri.parse('https://$url'); + } + // Probe the provided API endpoint as-is. + final target = uri; + final res = await http.get(target).timeout(const Duration(seconds: 10)); + debugPrint( + 'Endpoint probe ${target.toString()} -> ${res.statusCode} ${res.body}', + ); + if (res.statusCode < 200 || res.statusCode >= 300) return null; + final body = res.body.trim(); + debugPrint('Endpoint probe body: $body'); + // Try JSON first + String? version; + try { + final parsed = jsonDecode(body); + debugPrint('Endpoint probe parsed: $parsed'); + if (parsed is Map && parsed['version'] is String) { + version = parsed['version'] as String; + } else if (parsed is String) { + final candidate = parsed.trim().replaceAll('"', ''); + if (RegExp(r'^\d+\.\d+\.\d+$').hasMatch(candidate)) { + version = candidate; + } + } + } catch (_) { + // fall back to raw body parsing + } + version ??= body.split(RegExp(r'\s+')).firstWhere( + (part) => RegExp(r'^\d+\.\d+\.\d+$').hasMatch(part), + orElse: () => '', + ); + if (version.isEmpty) return null; + final isValid = RegExp(r'^\d+\.\d+\.\d+$').hasMatch(version); + return isValid ? version : null; + } catch (_) { + return null; + } + } + + Future _save() async { + final endpointService = context.read(); + final dataService = context.read(); + final messenger = ScaffoldMessenger.of(context); + final value = _endpointController.text.trim(); + if (value.isEmpty) { + messenger.showSnackBar( + const SnackBar(content: Text('Please enter an endpoint URL.')), + ); + return; + } + setState(() => _saving = true); + try { + final version = await _probeVersion(value); + if (version == null) { + if (mounted) { + messenger.showSnackBar( + const SnackBar( + content: Text('Endpoint test failed: no valid version returned.'), + ), + ); + } + return; + } + await endpointService.setBaseUrl(value); + if (mounted) { + messenger.showSnackBar( + SnackBar(content: Text('Endpoint set to "$value" ($version)')), + ); + await Future.wait([ + dataService.fetchHomepageStats(), + dataService.fetchOnThisDay(), + dataService.fetchTrips(), + dataService.fetchHadTraction(), + dataService.fetchLatestLocoChanges(), + dataService.fetchLegs(), + ]); + } + } catch (e) { + if (mounted) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to save endpoint: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _saving = false); + } + } + } + + @override + Widget build(BuildContext context) { + final endpointService = context.watch(); + if (!endpointService.isLoaded) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(); + } else { + context.go('/'); + } + }, + ), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'API endpoint', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Set the base URL for the Mileograph API. Leave blank to use the default.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + TextField( + controller: _endpointController, + decoration: const InputDecoration( + labelText: 'Endpoint URL', + hintText: 'https://mileograph.co.uk/api/v1', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save), + label: const Text('Save endpoint'), + ), + const SizedBox(width: 12), + TextButton( + onPressed: _saving + ? null + : () { + _endpointController.text = + EndpointService.defaultBaseUrl; + }, + child: const Text('Reset to default'), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Current: ${endpointService.baseUrl}', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ); + } +} diff --git a/lib/components/pages/trips.dart b/lib/components/pages/trips.dart index fa1e4f7..d9608f6 100644 --- a/lib/components/pages/trips.dart +++ b/lib/components/pages/trips.dart @@ -288,6 +288,11 @@ class _TripsPageState extends State { Future handleDelete() async { if (deleting || trip.legs.isNotEmpty) return; + final data = context.read(); + final api = data.api; + final messenger = ScaffoldMessenger.maybeOf(sheetCtx); + final navigator = Navigator.of(sheetCtx); + final ok = await showDialog( context: sheetCtx, builder: (ctx) { @@ -309,11 +314,8 @@ class _TripsPageState extends State { ); }, ); - if (ok != true) return; + if (ok != true || !mounted) return; - final data = context.read(); - final api = data.api; - final messenger = ScaffoldMessenger.maybeOf(context); setSheetState(() => deleting = true); try { await api.delete('/trips/delete/${trip.id}'); @@ -321,18 +323,16 @@ class _TripsPageState extends State { data.fetchTripDetails(), data.fetchTrips(), ]); - if (context.mounted) { - messenger?.showSnackBar( - SnackBar(content: Text('Deleted "${trip.name}"')), - ); - Navigator.of(sheetCtx).pop(); - } + if (!mounted) return; + messenger?.showSnackBar( + SnackBar(content: Text('Deleted "${trip.name}"')), + ); + navigator.pop(); } catch (e) { - if (context.mounted) { - messenger?.showSnackBar( - SnackBar(content: Text('Failed to delete trip: $e')), - ); - } + if (!mounted) return; + messenger?.showSnackBar( + SnackBar(content: Text('Failed to delete trip: $e')), + ); } finally { if (mounted) setSheetState(() => deleting = false); } diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index 5666b1a..1d1b01e 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -197,7 +197,7 @@ class LocoSummary extends Loco { this.livery, this.location, Map? extra, - bool powering = true, + super.powering = true, }) : extra = extra ?? const {}, super( id: locoId, @@ -207,7 +207,6 @@ class LocoSummary extends Loco { operator: locoOperator, notes: locoNotes, evn: locoEvn, - powering: powering, ); factory LocoSummary.fromJson(Map json) => LocoSummary( @@ -400,7 +399,7 @@ class LocoChange { }); factory LocoChange.fromJson(Map json) { - String _clean(dynamic value) { + String cleanValue(dynamic value) { final str = value?.toString().trim() ?? ''; if (str.isEmpty || str == '-' || str == '?') return ''; return str; @@ -417,15 +416,15 @@ class LocoChange { final validFromRaw = json['valid_from'] ?? json['validFrom']; return LocoChange( locoId: _asInt(json['loco_id']), - locoClass: _clean(json['loco_class']), - locoNumber: _clean(json['loco_number']), - locoName: _clean(json['loco_name']), + locoClass: cleanValue(json['loco_class']), + locoNumber: cleanValue(json['loco_number']), + locoName: cleanValue(json['loco_name']), attrCode: _asString(json['attr_code']), - attrDisplay: _clean(json['attr_display']), - valueDisplay: _clean(valueLabel), + attrDisplay: cleanValue(json['attr_display']), + valueDisplay: cleanValue(valueLabel), validFrom: DateTime.tryParse(validFromRaw?.toString() ?? ''), approvedAt: DateTime.tryParse(approvedRaw?.toString() ?? ''), - approvedBy: _clean(json['approved_by']), + approvedBy: cleanValue(json['approved_by']), ); } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 9fd805b..fee6f25 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -5,17 +5,24 @@ typedef TokenProvider = String? Function(); typedef UnauthorizedHandler = Future Function(); class ApiService { - final String baseUrl; + String _baseUrl; final http.Client _client; final Duration timeout; TokenProvider? _getToken; UnauthorizedHandler? _onUnauthorized; ApiService({ - required this.baseUrl, + required String baseUrl, http.Client? client, this.timeout = const Duration(seconds: 30), - }) : _client = client ?? http.Client(); + }) : _baseUrl = baseUrl, + _client = client ?? http.Client(); + + String get baseUrl => _baseUrl; + + void setBaseUrl(String url) { + _baseUrl = url; + } void setTokenProvider(TokenProvider provider) { _getToken = provider; diff --git a/lib/services/endpoint_service.dart b/lib/services/endpoint_service.dart new file mode 100644 index 0000000..d858b2c --- /dev/null +++ b/lib/services/endpoint_service.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class EndpointService extends ChangeNotifier { + EndpointService() { + _load(); + } + + static const String defaultBaseUrl = 'https://mileograph.co.uk/api/v1'; + static const String _prefsKey = 'api_base_url'; + + String _baseUrl = defaultBaseUrl; + bool _loaded = false; + + String get baseUrl => _baseUrl; + bool get isLoaded => _loaded; + + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString(_prefsKey); + if (saved != null && saved.trim().isNotEmpty) { + _baseUrl = saved; + } + _loaded = true; + notifyListeners(); + } + + Future setBaseUrl(String url) async { + final trimmed = url.trim(); + if (trimmed.isEmpty) return; + _baseUrl = trimmed; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, _baseUrl); + notifyListeners(); + } +} diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index 1a57111..366cce8 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -11,6 +11,7 @@ import 'package:mileograph_flutter/components/pages/loco_legs.dart'; import 'package:mileograph_flutter/components/pages/loco_timeline.dart'; import 'package:mileograph_flutter/components/pages/new_entry.dart'; import 'package:mileograph_flutter/components/pages/new_traction.dart'; +import 'package:mileograph_flutter/components/pages/settings.dart'; import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/components/pages/trips.dart'; import 'package:mileograph_flutter/services/authservice.dart'; @@ -154,6 +155,10 @@ class _MyAppState extends State { return NewEntryPage(editLegId: legId); }, ), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + ), ], ), GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), @@ -334,6 +339,11 @@ class _MyHomePageState extends State { onPressed: null, icon: Icon(Icons.account_circle), ), + IconButton( + tooltip: 'Settings', + onPressed: () => context.go('/settings'), + icon: const Icon(Icons.settings), + ), IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)), ], ), diff --git a/pubspec.yaml b/pubspec.yaml index 002b174..03faeab 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.3.1+1 +version: 0.3.2+1 environment: sdk: ^3.8.1