feat: working connection, conn setting, and gear ratio setting for universal shifters
This commit is contained in:
740
lib/pages/device_details_page.dart
Normal file
740
lib/pages/device_details_page.dart
Normal file
@ -0,0 +1,740 @@
|
||||
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 [];
|
||||
|
||||
@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(() {
|
||||
_gearRatios = result.unwrap();
|
||||
_isGearRatiosLoading = false;
|
||||
_hasLoadedGearRatios = true;
|
||||
_gearRatiosError = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> _saveGearRatios(List<double> ratios) async {
|
||||
final shifter = _shifterService;
|
||||
if (shifter == null) {
|
||||
return 'Status channel is not ready yet.';
|
||||
}
|
||||
|
||||
final result = await shifter.writeGearRatios(ratios);
|
||||
if (result.isErr()) {
|
||||
return 'Could not save gear ratios: ${result.unwrapErr()}';
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_gearRatios = List<double>.from(ratios);
|
||||
_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,
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,17 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
|
||||
import 'package:abawo_bt_app/util/constants.dart';
|
||||
import 'package:anyhow/anyhow.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
|
||||
import 'package:abawo_bt_app/widgets/device_listitem.dart';
|
||||
import 'package:abawo_bt_app/widgets/scanning_animation.dart'; // Import the new animation widget
|
||||
import 'package:abawo_bt_app/widgets/horizontal_scanning_animation.dart'; // Import the new horizontal animation
|
||||
import 'package:abawo_bt_app/database/database.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
|
||||
const Duration _scanDuration = Duration(seconds: 10);
|
||||
|
||||
@ -18,25 +24,20 @@ class ConnectDevicePage extends ConsumerStatefulWidget {
|
||||
|
||||
class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
|
||||
with TickerProviderStateMixin {
|
||||
// Use TickerProviderStateMixin for multiple controllers if needed later, good practice
|
||||
bool _initialScanStarted = false;
|
||||
// TickerProviderStateMixin is no longer needed as animations are self-contained or handled by StreamBuilder
|
||||
int _retryScanCounter = 0; // Used to force animation reset
|
||||
bool _initialScanTriggered = false; // Track if the first scan was requested
|
||||
bool _showOnlyAbawoDevices = true; // State for filtering devices
|
||||
late AnimationController
|
||||
_progressController; // Controller for scan duration progress
|
||||
late AnimationController
|
||||
_waveAnimationController; // Controller for wave animation
|
||||
|
||||
// Function to start scan safely after controller is ready
|
||||
void _startScanIfNeeded(BluetoothController controller) {
|
||||
// Use WidgetsBinding to schedule the scan start after the build phase
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_initialScanStarted && mounted) {
|
||||
// Start scan only if it hasn't been triggered yet and the widget is mounted
|
||||
if (!_initialScanTriggered && mounted) {
|
||||
controller.startScan(timeout: _scanDuration);
|
||||
_startScanProgressAnimation(); // Start scan duration progress animation
|
||||
_startWaveAnimation(); // Start the wave animation
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_initialScanStarted = true;
|
||||
_initialScanTriggered = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -46,64 +47,17 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize scan progress controller
|
||||
_progressController = AnimationController(
|
||||
vsync: this,
|
||||
duration: _scanDuration,
|
||||
)..addListener(() {
|
||||
// Trigger rebuild when animation value changes for the progress indicator
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize wave animation controller
|
||||
_waveAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500), // New duration: 1.5 seconds
|
||||
)..repeat(); // Make the wave animation repeat
|
||||
super.initState();
|
||||
// No animation controllers needed here anymore
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Stop animations before disposing
|
||||
_progressController.stop();
|
||||
_waveAnimationController.stop();
|
||||
_progressController.dispose();
|
||||
_waveAnimationController.dispose();
|
||||
// Dispose controllers if they existed (they don't anymore)
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Helper method to start/reset scan progress animation
|
||||
void _startScanProgressAnimation() {
|
||||
if (mounted) {
|
||||
_progressController.reset();
|
||||
_progressController.forward().whenCompleteOrCancel(() {
|
||||
// Optional: Add logic if needed when scan progress completes or cancels
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to start the wave animation
|
||||
void _startWaveAnimation() {
|
||||
if (mounted && !_waveAnimationController.isAnimating) {
|
||||
_waveAnimationController.reset();
|
||||
_waveAnimationController.repeat();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to stop animations when scan finishes/cancels
|
||||
void _stopAnimations() {
|
||||
if (mounted) {
|
||||
if (_progressController.isAnimating) {
|
||||
_progressController.stop();
|
||||
}
|
||||
if (_waveAnimationController.isAnimating) {
|
||||
_waveAnimationController.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Removed _startScanProgressAnimation, _startWaveAnimation, _stopAnimations
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@ -134,155 +88,264 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
body: Column(
|
||||
// Use Column instead of Center(Column(...))
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0), // Add padding around the title
|
||||
child: Text(
|
||||
'Available Devices',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Use Consumer to react to bluetoothProvider changes
|
||||
Expanded(
|
||||
// Allow the Consumer content to expand
|
||||
child: Consumer(builder: (context, ref, child) {
|
||||
),
|
||||
// Use Consumer to get the BluetoothController
|
||||
Expanded(
|
||||
// Allow the device list to take available space
|
||||
child: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final btAsyncValue = ref.watch(bluetoothProvider);
|
||||
final connectedDevices =
|
||||
ref.watch(nConnectedDevicesProvider).valueOrNull ??
|
||||
const <ConnectedDevice>[];
|
||||
final connectedDeviceAddresses = connectedDevices
|
||||
.map((device) => device.deviceAddress)
|
||||
.toSet();
|
||||
|
||||
return btAsyncValue.when(
|
||||
loading: () => const Center(
|
||||
child:
|
||||
CircularProgressIndicator()), // Center loading indicator
|
||||
error: (err, stack) => Center(
|
||||
child: Text(
|
||||
'Error loading Bluetooth: $err')), // Center error
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
data: (controller) {
|
||||
// Start the initial scan once the controller is ready
|
||||
// Start initial scan and animation
|
||||
// Trigger the initial scan if needed
|
||||
_startScanIfNeeded(controller);
|
||||
|
||||
// Use StreamBuilder to watch the scanning state
|
||||
return StreamBuilder<bool>(
|
||||
stream: FlutterBluePlus.isScanning,
|
||||
initialData:
|
||||
false, // Default to not scanning before check
|
||||
// StreamBuilder for Scan Results (Device List)
|
||||
return StreamBuilder<List<DiscoveredDevice>>(
|
||||
stream: controller.scanResultsStream,
|
||||
initialData: const [],
|
||||
builder: (context, snapshot) {
|
||||
final isScanning = snapshot.data ?? false;
|
||||
final results = snapshot.data ?? [];
|
||||
// Filter results based on the toggle state
|
||||
final filteredResults = _showOnlyAbawoDevices
|
||||
? results
|
||||
.where((device) =>
|
||||
device.serviceUuids.any(isAbawoDeviceGuid))
|
||||
.toList()
|
||||
: results;
|
||||
|
||||
if (isScanning && _initialScanStarted) {
|
||||
_startWaveAnimation(); // Ensure wave animation is running
|
||||
// Show the new scanning wave animation with the progress indicator value
|
||||
return Center(
|
||||
// Pass the wave animation controller. The progress value will be added
|
||||
// to the ScanningWaveAnimation widget itself later.
|
||||
child: ScanningWaveAnimation(
|
||||
animation: _waveAnimationController,
|
||||
progressValue: _progressController
|
||||
.value, // Pass the scan progress
|
||||
),
|
||||
);
|
||||
} else if (!_initialScanStarted) {
|
||||
// Show placeholder or button to start initial scan if needed, or just empty space
|
||||
return const SizedBox(
|
||||
height: 50); // Placeholder before scan starts
|
||||
} else {
|
||||
// Scan finished, stop animations and show results
|
||||
_stopAnimations();
|
||||
final results = controller.scanResults;
|
||||
// Filter results based on the toggle state
|
||||
final filteredResults = _showOnlyAbawoDevices
|
||||
? results
|
||||
.where((device) => device
|
||||
.advertisementData.serviceUuids
|
||||
.contains(Guid(abawoServiceBtUUID)))
|
||||
.toList()
|
||||
: results;
|
||||
|
||||
// Use Column + Expanded for ListView + Button layout
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
// Allow ListView to take available space
|
||||
child: filteredResults
|
||||
.isEmpty // Use filtered list check
|
||||
? const Center(
|
||||
child: Text(
|
||||
'No devices found.')) // Center empty text
|
||||
: ListView.builder(
|
||||
itemCount: filteredResults
|
||||
.length, // Use filtered list length
|
||||
itemBuilder: (context, index) {
|
||||
final device = filteredResults[
|
||||
index]; // Use filtered list
|
||||
final isAbawoDevice = device
|
||||
.advertisementData.serviceUuids
|
||||
.contains(
|
||||
Guid(abawoServiceBtUUID));
|
||||
final deviceName =
|
||||
device.device.advName.isEmpty
|
||||
? 'Unknown Device'
|
||||
: device.device.advName;
|
||||
// Use the custom DeviceListItem widget
|
||||
return InkWell(
|
||||
// Wrap with InkWell for tap feedback
|
||||
onTap: () {
|
||||
if (!isAbawoDevice) {
|
||||
// Show a snackbar for non-Abawo devices
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(
|
||||
content: Text(
|
||||
'This app can only connect to abawo devices.')));
|
||||
return;
|
||||
}
|
||||
// TODO: Implement connect logic
|
||||
// controller.connectToDevice(device.device); // Pass the BluetoothDevice
|
||||
// context.go('/control/${device.device.remoteId.str}');
|
||||
print(
|
||||
'Tapped on ${device.device.remoteId.str}');
|
||||
},
|
||||
child: DeviceListItem(
|
||||
deviceName: deviceName,
|
||||
deviceId:
|
||||
device.device.remoteId.str,
|
||||
isUnknownDevice:
|
||||
device.device.advName.isEmpty,
|
||||
),
|
||||
);
|
||||
} // End of itemBuilder
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
// Add padding around the button
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
// Retry scan by calling startScan on the controller
|
||||
// Ensure _initialScanStarted is true so indicator shows
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_initialScanStarted = true;
|
||||
});
|
||||
}
|
||||
controller.startScan(
|
||||
timeout: _scanDuration);
|
||||
_startScanProgressAnimation(); // Restart scan progress animation
|
||||
_startWaveAnimation(); // Ensure wave animation runs on retry
|
||||
},
|
||||
child: const Text('Retry Scan'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!_initialScanTriggered && filteredResults.isEmpty) {
|
||||
// Show a message or placeholder before the first scan starts or if no devices found initially
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Scanning for devices...')); // Or CircularProgressIndicator()
|
||||
}
|
||||
|
||||
if (filteredResults.isEmpty && _initialScanTriggered) {
|
||||
// Show 'No devices found' only after the initial scan was triggered
|
||||
return const Center(child: Text('No devices found.'));
|
||||
}
|
||||
|
||||
// Display the list
|
||||
return ListView.builder(
|
||||
itemCount: filteredResults.length,
|
||||
itemBuilder: (context, index) {
|
||||
final device = filteredResults[index];
|
||||
final isAlreadyConnected =
|
||||
connectedDeviceAddresses.contains(device.id);
|
||||
final abawoDevice =
|
||||
device.serviceUuids.any(isAbawoDeviceGuid);
|
||||
final connectable = device.serviceUuids
|
||||
.any(isConnectableAbawoDeviceGuid);
|
||||
final deviceName = device.name.isEmpty
|
||||
? 'Unknown Device'
|
||||
: device.name;
|
||||
|
||||
return InkWell(
|
||||
onTap: () async {
|
||||
if (isAlreadyConnected) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'This device is already connected in the app.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!abawoDevice) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'This app can only connect to abawo devices.')),
|
||||
);
|
||||
return;
|
||||
} else if (!connectable) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'This device is not connectable with the app.')),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
final res = await controller.connect(device);
|
||||
print('res: $res');
|
||||
switch (res) {
|
||||
case Ok():
|
||||
// trigger pairing/permission prompt if needed
|
||||
if (!Platform.isAndroid) {
|
||||
controller.readCharacteristic(
|
||||
device.id,
|
||||
'0993826f-0ee4-4b37-9614-d13ecba4ffc2',
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40000');
|
||||
}
|
||||
// Save to DB and navigate
|
||||
final notifier = ref.read(
|
||||
nConnectedDevicesProvider.notifier);
|
||||
final name = device.name.isNotEmpty
|
||||
? device.name
|
||||
: 'Unknown Device';
|
||||
final deviceCompanion =
|
||||
ConnectedDevicesCompanion(
|
||||
deviceName: Value(name),
|
||||
deviceAddress: Value(device.id),
|
||||
deviceType: Value(deviceTypeToString(
|
||||
deviceTypeFromUuids(
|
||||
device.serviceUuids))),
|
||||
lastConnectedAt: Value(DateTime.now()),
|
||||
);
|
||||
final addResult = await notifier
|
||||
.addConnectedDevice(deviceCompanion);
|
||||
|
||||
// Check if mounted before using context
|
||||
if (!context.mounted) break;
|
||||
|
||||
if (addResult.isErr()) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Failed to save device: ${addResult.unwrapErr()}')),
|
||||
);
|
||||
} else {
|
||||
context.go('/device/${device.id}');
|
||||
}
|
||||
break;
|
||||
case Err(:final v):
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
'Connection unsuccessful:\n${v.toString()}'),
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
print('Tapped on ${device.id}');
|
||||
},
|
||||
child: DeviceListItem(
|
||||
deviceName: deviceName,
|
||||
deviceId: device.id,
|
||||
type: deviceTypeFromUuids(device.serviceUuids),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom section: Scanning Animation and Retry Button (visible only when scanning)
|
||||
Consumer(
|
||||
// Use Consumer to get the controller for the retry button action
|
||||
builder: (context, ref, child) {
|
||||
final btController = ref
|
||||
.watch(bluetoothProvider)
|
||||
.asData
|
||||
?.value; // Get controller safely
|
||||
|
||||
return StreamBuilder<bool>(
|
||||
stream: btController?.isScanningStream ?? Stream<bool>.empty(),
|
||||
initialData: false,
|
||||
builder: (context, snapshot) {
|
||||
final isScanning = snapshot.data ?? false;
|
||||
|
||||
// Show bottom section only if scanning
|
||||
if (!isScanning) {
|
||||
// Show only the retry button when not scanning (optional, could be hidden)
|
||||
// For now, let's keep the button always visible but disabled when not scannable.
|
||||
// A better approach might be to hide the button when not scanning.
|
||||
// Let's show the button but potentially disabled later if controller is null.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: btController != null
|
||||
? () {
|
||||
// Retry scan ONLY when NOT currently scanning
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_initialScanTriggered =
|
||||
true; // Ensure state reflects scan attempt
|
||||
_retryScanCounter++; // Increment key counter
|
||||
});
|
||||
}
|
||||
btController.startScan(timeout: _scanDuration);
|
||||
}
|
||||
: null, // Disable if controller not ready
|
||||
child: const Text('Retry Scan'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// If scanning, show animation and button
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0, horizontal: 16.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.scaffoldBackgroundColor
|
||||
.withValues(alpha: 0.9), // Slight overlay effect
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -4), // Shadow upwards
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min, // Keep column compact
|
||||
children: [
|
||||
// Pass isScanning and the ValueKey
|
||||
HorizontalScanningAnimation(
|
||||
key: ValueKey(
|
||||
_retryScanCounter), // Force state rebuild on counter change
|
||||
isScanning: isScanning,
|
||||
height: 40,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
// Button does nothing if pressed *while* scanning.
|
||||
// It just indicates the status.
|
||||
onPressed: null, // Disable button while scanning
|
||||
style: ElevatedButton.styleFrom(
|
||||
disabledBackgroundColor: Theme.of(context)
|
||||
.primaryColor
|
||||
.withValues(alpha: 0.5), // Custom disabled color
|
||||
disabledForegroundColor: Colors.white70,
|
||||
),
|
||||
child:
|
||||
const Text('Scanning...'), // Just indicate status
|
||||
),
|
||||
const SizedBox(height: 8), // Add some bottom padding
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
], // End of outer Column children
|
||||
), // End of Scaffold
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/database/database.dart';
|
||||
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
|
||||
import 'package:abawo_bt_app/widgets/device_listitem.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
@ -53,10 +58,7 @@ class HomePage extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'No devices connected yet',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
child: DevicesList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -73,3 +75,148 @@ class HomePage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DevicesList extends ConsumerStatefulWidget {
|
||||
const DevicesList({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DevicesList> createState() => _DevicesListState();
|
||||
}
|
||||
|
||||
class _DevicesListState extends ConsumerState<DevicesList> {
|
||||
String? _connectingDeviceId; // ID of device currently being connected
|
||||
|
||||
Future<void> _removeDevice(ConnectedDevice device) async {
|
||||
final shouldRemove = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Remove device?'),
|
||||
content:
|
||||
Text('Do you want to remove ${device.deviceName} from the app?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldRemove != true || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref
|
||||
.read(nConnectedDevicesProvider.notifier)
|
||||
.deleteConnectedDevice(device.id);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.isErr()) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to remove device: ${result.unwrapErr()}')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_connectingDeviceId == device.deviceAddress) {
|
||||
setState(() {
|
||||
_connectingDeviceId = null;
|
||||
});
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${device.deviceName} removed from the app.')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asyncDevices = ref.watch(nConnectedDevicesProvider);
|
||||
|
||||
return asyncDevices.when(
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
error: (error, stackTrace) => Center(
|
||||
child: Text(
|
||||
'Error loading devices: ${error.toString()}',
|
||||
style: TextStyle(color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
data: (devices) {
|
||||
if (devices.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No devices connected yet',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: devices.length,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
final device = devices[index];
|
||||
return InkWell(
|
||||
onTap: () async {
|
||||
if (_connectingDeviceId != null) return;
|
||||
setState(() {
|
||||
_connectingDeviceId = device.deviceAddress;
|
||||
});
|
||||
|
||||
try {
|
||||
final controller = await ref.read(bluetoothProvider.future);
|
||||
final result = await controller.connectById(
|
||||
device.deviceAddress,
|
||||
timeout: const Duration(seconds: 10),
|
||||
);
|
||||
|
||||
if (result.isOk()) {
|
||||
context.go('/device/${device.deviceAddress}');
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Connection failed. Is the device turned on and in range?'),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: ${e.toString()}')),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_connectingDeviceId = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: DeviceListItem(
|
||||
deviceName: device.deviceName,
|
||||
deviceId: device.deviceAddress,
|
||||
type: deviceTypeFromString(device.deviceType),
|
||||
isConnecting: device.deviceAddress == _connectingDeviceId,
|
||||
trailing: IconButton(
|
||||
tooltip: 'Remove device',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () => _removeDevice(device),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user