import 'dart:async'; import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:abawo_bt_app/service/shifter_service.dart'; import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart'; import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:nb_utils/nb_utils.dart'; import '../controller/bluetooth.dart'; import '../database/database.dart'; class DeviceDetailsPage extends ConsumerStatefulWidget { const DeviceDetailsPage({ required this.deviceAddress, super.key, }); final String deviceAddress; @override ConsumerState createState() => _DeviceDetailsPageState(); } class _DeviceDetailsPageState extends ConsumerState { static const List _keAntRatios = [ 0.35, 0.40, 0.47, 0.54, 0.61, 0.69, 0.82, 0.95, 1.13, 1.29, 1.50, 1.71, 1.89, 2.12, 2.40, 2.77, 3.27, ]; bool _isReconnecting = false; bool _wasConnectedToCurrentDevice = false; bool _isExitingPage = false; bool _hasRequestedDisconnect = false; Timer? _reconnectTimeoutTimer; ProviderSubscription>? _connectionStatusSubscription; ShifterService? _shifterService; StreamSubscription? _statusSubscription; CentralStatus? _latestStatus; final List<_StatusHistoryEntry> _statusHistory = []; bool _isGearRatiosLoading = false; bool _hasLoadedGearRatios = false; String? _gearRatiosError; List _gearRatios = const []; int _defaultGearIndex = 0; @override void initState() { super.initState(); _connectionStatusSubscription = ref.listenManual>( connectionStatusProvider, (_, next) { final data = next.valueOrNull; if (data == null) { return; } _onConnectionStatusChanged(data); }, fireImmediately: true, ); } @override void dispose() { unawaited(_disconnectOnClose()); _reconnectTimeoutTimer?.cancel(); _connectionStatusSubscription?.close(); _statusSubscription?.cancel(); _shifterService?.dispose(); super.dispose(); } Future _disconnectOnClose() async { if (_hasRequestedDisconnect) { return; } _hasRequestedDisconnect = true; _isExitingPage = true; _reconnectTimeoutTimer?.cancel(); final bluetooth = ref.read(bluetoothProvider).value; await bluetooth?.disconnect(); await _stopStatusStreaming(); } void _onConnectionStatusChanged((ConnectionStatus, String?) data) { if (!mounted || _isExitingPage) { return; } final (status, connectedDeviceId) = data; final isCurrentDevice = connectedDeviceId == widget.deviceAddress; if (isCurrentDevice && status == ConnectionStatus.connected) { _wasConnectedToCurrentDevice = true; _startStatusStreamingIfNeeded(); if (_isReconnecting) { _reconnectTimeoutTimer?.cancel(); setState(() { _isReconnecting = false; }); } return; } if (_wasConnectedToCurrentDevice && !_isReconnecting && status == ConnectionStatus.disconnected) { _startReconnect(); } if (!isCurrentDevice || status == ConnectionStatus.disconnected) { _stopStatusStreaming(); } } Future _startReconnect() async { if (!mounted || _isExitingPage || _isReconnecting) { return; } setState(() { _isReconnecting = true; }); final bluetooth = ref.read(bluetoothProvider).value; await bluetooth?.connectById(widget.deviceAddress); _reconnectTimeoutTimer?.cancel(); _reconnectTimeoutTimer = Timer(const Duration(seconds: 10), () { if (!mounted || !_isReconnecting || _isExitingPage) { return; } _terminateConnectionAndGoHome( 'Connection lost. Could not reconnect in time.', ); }); } Future _startStatusStreamingIfNeeded() async { if (_shifterService != null) { if (!_hasLoadedGearRatios && !_isGearRatiosLoading) { unawaited(_loadGearRatios()); } return; } final asyncBluetooth = ref.read(bluetoothProvider); final BluetoothController bluetooth; if (asyncBluetooth.hasValue) { bluetooth = asyncBluetooth.requireValue; } else { bluetooth = await ref.read(bluetoothProvider.future); } final service = ShifterService( bluetooth: bluetooth, buttonDeviceId: widget.deviceAddress, ); final initialStatusResult = await service.readStatus(); if (mounted && initialStatusResult.isOk()) { _recordStatus(initialStatusResult.unwrap()); } _statusSubscription = service.statusStream.listen((status) { if (!mounted) { return; } _recordStatus(status); }); service.startStatusNotifications(); setState(() { _shifterService = service; }); unawaited(_loadGearRatios()); } void _recordStatus(CentralStatus status) { setState(() { _latestStatus = status; _statusHistory.insert( 0, _StatusHistoryEntry( timestamp: DateTime.now(), status: status, ), ); if (_statusHistory.length > 100) { _statusHistory.removeRange(100, _statusHistory.length); } }); } Future _stopStatusStreaming() async { await _statusSubscription?.cancel(); _statusSubscription = null; await _shifterService?.dispose(); _shifterService = null; } Future _loadGearRatios() async { final shifter = _shifterService; if (shifter == null || _isGearRatiosLoading) { return; } setState(() { _isGearRatiosLoading = true; _gearRatiosError = null; }); final result = await shifter.readGearRatios(); if (!mounted) { return; } if (result.isErr()) { setState(() { _gearRatiosError = 'Failed to read gear ratios: ${result.unwrapErr()}'; _isGearRatiosLoading = false; _hasLoadedGearRatios = false; }); return; } setState(() { final data = result.unwrap(); _gearRatios = data.ratios; _defaultGearIndex = data.defaultGearIndex; _isGearRatiosLoading = false; _hasLoadedGearRatios = true; _gearRatiosError = null; }); } Future _saveGearRatios( List ratios, int defaultGearIndex) async { final shifter = _shifterService; if (shifter == null) { return 'Status channel is not ready yet.'; } final result = await shifter.writeGearRatios( GearRatiosData( ratios: ratios, defaultGearIndex: defaultGearIndex, ), ); if (result.isErr()) { return 'Could not save gear ratios: ${result.unwrapErr()}'; } if (!mounted) { return null; } setState(() { _gearRatios = List.from(ratios); _defaultGearIndex = ratios.isEmpty ? 0 : defaultGearIndex.clamp(0, ratios.length - 1).toInt(); _hasLoadedGearRatios = true; _gearRatiosError = null; }); return null; } Future _connectButtonToBike() async { final selectedBike = await BikeScanDialog.show( context, excludedDeviceId: widget.deviceAddress, ); if (selectedBike == null || !mounted) { return; } await _startStatusStreamingIfNeeded(); final shifter = _shifterService; if (shifter == null) { if (!mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Status channel is not ready yet.')), ); return; } final result = await shifter.connectButtonToBike(selectedBike.id); if (!mounted) { return; } if (result.isErr()) { final err = result.unwrapErr(); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Connect request failed: $err')), ); toast('Connect request failed.'); return; } ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Sent connect request for ${selectedBike.id}.')), ); } Future _terminateConnectionAndGoHome(String toastMessage) async { await _disconnectOnClose(); if (!mounted) { return; } toast(toastMessage); context.replace('/'); } Future _cancelReconnect() async { await _terminateConnectionAndGoHome('Reconnect cancelled.'); } Future _exitPage() async { await _disconnectOnClose(); if (!mounted) { return; } context.replace('/'); } void _showStatusHistory() { showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (context) { return SizedBox( height: MediaQuery.of(context).size.height * 0.72, child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Row( children: [ const Expanded( child: Text( 'Status Console', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, ), ), ), ], ), ), const Divider(height: 1), Expanded( child: _statusHistory.isEmpty ? const Center(child: Text('No status updates yet.')) : ListView.builder( itemCount: _statusHistory.length, itemBuilder: (context, index) { final item = _statusHistory[index]; final errorCode = _effectiveErrorCode(item.status); return ListTile( dense: true, visualDensity: VisualDensity.compact, title: Text( item.status.statusLine, style: const TextStyle(fontSize: 13), ), subtitle: Text( _formatTimestamp(item.timestamp), style: const TextStyle( fontFamily: 'monospace', fontSize: 12, ), ), trailing: errorCode == null ? null : IconButton( tooltip: 'Explain error', onPressed: () { _showErrorInfoDialog(errorCode); }, icon: const Icon(Icons.info_outline), ), onTap: errorCode == null ? null : () { _showErrorInfoDialog(errorCode); }, ); }, ), ), ], ), ); }, ); } String _formatTimestamp(DateTime time) { final h = time.hour.toString().padLeft(2, '0'); final m = time.minute.toString().padLeft(2, '0'); final s = time.second.toString().padLeft(2, '0'); return '$h:$m:$s'; } int? _effectiveErrorCode(CentralStatus status) { if (status.trainer.state == TrainerConnectionState.error) { return status.trainer.errorCode ?? status.lastFailure; } return status.lastFailure; } void _showErrorInfoDialog(int errorCode) { final info = shifterErrorInfo(errorCode); showDialog( context: context, builder: (context) { return AlertDialog( icon: const Icon(Icons.info_outline), title: Text('Error ${info.code}: ${info.title}'), content: Text(info.details), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Close'), ), ], ); }, ); } @override Widget build(BuildContext context) { final connectionData = ref.watch(connectionStatusProvider).valueOrNull; final isCurrentConnected = connectionData != null && connectionData.$1 == ConnectionStatus.connected && connectionData.$2 == widget.deviceAddress; return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, bool? result) { _exitPage(); }, child: Scaffold( appBar: AppBar( title: const Text('Device Details'), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: _exitPage, ), ), body: Stack( fit: StackFit.expand, children: [ SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildDeviceInfo(context, ref, widget.deviceAddress), const SizedBox(height: 16), _buildConnectionStatus(context, ref, widget.deviceAddress), const SizedBox(height: 16), if (isCurrentConnected) ...[ _StatusBanner( status: _latestStatus, onTap: _showStatusHistory, onErrorInfoTap: _latestStatus == null ? null : () { final code = _effectiveErrorCode(_latestStatus!); if (code != null) { _showErrorInfoDialog(code); } }, ), const SizedBox(height: 16), GearRatioEditorCard( ratios: _gearRatios, defaultGearIndex: _defaultGearIndex, isLoading: _isGearRatiosLoading, errorText: _gearRatiosError, onRetry: _loadGearRatios, onSave: _saveGearRatios, presets: const [ GearRatioPreset( name: 'KeAnt Classic', description: '17-step baseline from KeAnt cross app gearing.', ratios: _keAntRatios, ), ], ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: _connectButtonToBike, icon: const Icon(Icons.link), label: const Text('Connect Button to Bike'), ), ), ], ], ), ), if (_isReconnecting) Positioned.fill( child: ColoredBox( color: Colors.black.withValues(alpha: 0.55), child: Center( child: Container( margin: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(16), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), const SizedBox(height: 16), Text( 'Reconnecting...', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 16), TextButton( onPressed: _cancelReconnect, child: const Text('Cancel'), ), ], ), ), ), ), ), ], ), ), ); } } class _StatusHistoryEntry { const _StatusHistoryEntry({ required this.timestamp, required this.status, }); final DateTime timestamp; final CentralStatus status; } class _StatusBanner extends StatelessWidget { const _StatusBanner({ required this.status, required this.onTap, this.onErrorInfoTap, }); final CentralStatus? status; final VoidCallback onTap; final VoidCallback? onErrorInfoTap; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final color = _resolveColor(colorScheme); final text = status?.statusLine ?? 'Waiting for status updates...'; return Material( color: color.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(12), child: InkWell( borderRadius: BorderRadius.circular(12), onTap: onTap, child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ Icon(Icons.memory, color: color), const SizedBox(width: 10), Expanded( child: Text( text, maxLines: 2, overflow: TextOverflow.ellipsis, ), ), if (onErrorInfoTap != null && status != null && (status!.trainer.state == TrainerConnectionState.error || status!.lastFailure != null)) IconButton( tooltip: 'Explain error', onPressed: onErrorInfoTap, icon: Icon(Icons.info_outline, color: color), ), Icon(Icons.chevron_right, color: color), ], ), ), ), ); } Color _resolveColor(ColorScheme scheme) { final current = status; if (current == null) { return scheme.primary; } if (current.trainer.state == TrainerConnectionState.error) { return scheme.error; } if (current.trainer.state == TrainerConnectionState.ftmsReady) { return Colors.green; } if (current.trainer.state == TrainerConnectionState.connecting || current.trainer.state == TrainerConnectionState.pairing || current.trainer.state == TrainerConnectionState.discoveringFtms) { return Colors.orange; } return scheme.primary; } } Widget _buildDeviceInfo( BuildContext context, WidgetRef ref, String deviceAddress, ) { final asyncSavedDevices = ref.watch(nConnectedDevicesProvider); return asyncSavedDevices.when( data: (devices) { ConnectedDevice? currentDeviceData; try { currentDeviceData = devices.firstWhere( (d) => d.deviceAddress == deviceAddress, ); } catch (_) { currentDeviceData = null; } if (currentDeviceData == null) { return Center( child: Text('Device details not found for $deviceAddress.'), ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Name: ${currentDeviceData.deviceName}', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( 'Address: ${currentDeviceData.deviceAddress}', style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 8), Text( 'Type: ${currentDeviceData.deviceType}', style: Theme.of(context).textTheme.bodyMedium, ), ], ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (error, stackTrace) => Center(child: Text('Error loading device info: $error')), ); } Widget _buildConnectionStatus( BuildContext context, WidgetRef ref, String deviceAddress, ) { final asyncConnectionStatus = ref.watch(connectionStatusProvider); return asyncConnectionStatus.when( data: (data) { final (status, connectedDeviceId) = data; String statusText; final isCurrentDeviceConnected = connectedDeviceId != null && connectedDeviceId == deviceAddress; if (isCurrentDeviceConnected) { switch (status) { case ConnectionStatus.connected: statusText = 'Status: Connected'; break; case ConnectionStatus.connecting: statusText = 'Status: Connecting...'; break; case ConnectionStatus.disconnecting: statusText = 'Status: Disconnecting...'; break; case ConnectionStatus.disconnected: statusText = 'Status: Disconnected'; break; } } else { statusText = 'Status: Disconnected'; } return Text(statusText, style: Theme.of(context).textTheme.titleMedium); }, loading: () => const Text( 'Status: Unknown', style: TextStyle(fontStyle: FontStyle.italic), ), error: (error, stackTrace) => Text( 'Status: Error ($error)', style: const TextStyle(color: Colors.red), ), ); }