Files
abawo-bt-app/lib/pages/device_details_page.dart

754 lines
22 KiB
Dart

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<DeviceDetailsPage> createState() => _DeviceDetailsPageState();
}
class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
static const List<double> _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<AsyncValue<(ConnectionStatus, String?)>>?
_connectionStatusSubscription;
ShifterService? _shifterService;
StreamSubscription<CentralStatus>? _statusSubscription;
CentralStatus? _latestStatus;
final List<_StatusHistoryEntry> _statusHistory = [];
bool _isGearRatiosLoading = false;
bool _hasLoadedGearRatios = false;
String? _gearRatiosError;
List<double> _gearRatios = const [];
int _defaultGearIndex = 0;
@override
void initState() {
super.initState();
_connectionStatusSubscription =
ref.listenManual<AsyncValue<(ConnectionStatus, String?)>>(
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<void> _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<void> _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<void> _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<void> _stopStatusStreaming() async {
await _statusSubscription?.cancel();
_statusSubscription = null;
await _shifterService?.dispose();
_shifterService = null;
}
Future<void> _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<String?> _saveGearRatios(
List<double> 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<double>.from(ratios);
_defaultGearIndex = ratios.isEmpty
? 0
: defaultGearIndex.clamp(0, ratios.length - 1).toInt();
_hasLoadedGearRatios = true;
_gearRatiosError = null;
});
return null;
}
Future<void> _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<void> _terminateConnectionAndGoHome(String toastMessage) async {
await _disconnectOnClose();
if (!mounted) {
return;
}
toast(toastMessage);
context.replace('/');
}
Future<void> _cancelReconnect() async {
await _terminateConnectionAndGoHome('Reconnect cancelled.');
}
Future<void> _exitPage() async {
await _disconnectOnClose();
if (!mounted) {
return;
}
context.replace('/');
}
void _showStatusHistory() {
showModalBottomSheet<void>(
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<void>(
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),
),
);
}