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/accent_color_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/endpoint_service.dart'; import 'package:mileograph_flutter/services/theme_mode_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; static const List _accentPalette = [ Colors.red, Colors.pink, Colors.orange, Colors.amber, Colors.green, Colors.teal, Colors.blue, Colors.indigo, Colors.purple, Colors.cyan, ]; @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(); final distanceUnitService = context.watch(); final accentService = context.watch(); final themeModeService = context.watch(); if (!endpointService.isLoaded || !distanceUnitService.isLoaded || !accentService.isLoaded || !themeModeService.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('/more'); } }, ), ), body: SingleChildScrollView( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Distance units', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), Text( 'Choose how distances are displayed across the app.', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), SegmentedButton( segments: DistanceUnit.values .map( (unit) => ButtonSegment( value: unit, label: Text(unit.label), ), ) .toList(), selected: {distanceUnitService.unit}, onSelectionChanged: (selection) { final next = selection.first; distanceUnitService.setUnit(next); }, ), const SizedBox(height: 24), Text( 'Accent colour', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), Text( 'Choose your preferred accent colour or use system colours.', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), Wrap( spacing: 12, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ OutlinedButton.icon( onPressed: accentService.useSystem ? null : () => accentService.setUseSystem(true), icon: const Icon(Icons.phone_android), label: const Text('Use system colours'), ), ..._accentPalette.map( (color) => _AccentSwatchButton( color: color, selected: !accentService.useSystem && accentService.seedColor.toARGB32() == color.toARGB32(), onTap: () => accentService.setSeedColor(color), ), ), ], ), const SizedBox(height: 24), Text( 'Theme mode', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), SegmentedButton( segments: const [ ButtonSegment( value: ThemeMode.system, icon: Icon(Icons.settings_suggest), label: Text('System'), ), ButtonSegment( value: ThemeMode.light, icon: Icon(Icons.light_mode), label: Text('Light'), ), ButtonSegment( value: ThemeMode.dark, icon: Icon(Icons.dark_mode), label: Text('Dark'), ), ], selected: {themeModeService.mode}, onSelectionChanged: (selection) { final mode = selection.first; themeModeService.setMode(mode); }, ), const SizedBox(height: 24), 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, ), ], ), ), ); } } class _AccentSwatchButton extends StatelessWidget { const _AccentSwatchButton({ required this.color, required this.selected, required this.onTap, }); final Color color; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { final borderColor = selected ? Theme.of(context).colorScheme.onSurface : Colors.black26; return InkWell( onTap: onTap, customBorder: const CircleBorder(), child: Container( width: 40, height: 40, decoration: BoxDecoration( color: color, shape: BoxShape.circle, border: Border.all( color: borderColor, width: selected ? 3 : 1, ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.15), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: selected ? const Center( child: Icon( Icons.check, size: 18, color: Colors.white, ), ) : null, ), ); } }