352 lines
16 KiB
Dart
352 lines
16 KiB
Dart
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_reactive_ble/flutter_reactive_ble.dart';
|
|
import 'package:abawo_bt_app/widgets/device_listitem.dart';
|
|
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);
|
|
|
|
class ConnectDevicePage extends ConsumerStatefulWidget {
|
|
const ConnectDevicePage({super.key});
|
|
|
|
@override
|
|
ConsumerState<ConnectDevicePage> createState() => _ConnectDevicePageState();
|
|
}
|
|
|
|
class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
|
|
with TickerProviderStateMixin {
|
|
// 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
|
|
// 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((_) {
|
|
// Start scan only if it hasn't been triggered yet and the widget is mounted
|
|
if (!_initialScanTriggered && mounted) {
|
|
controller.startScan(timeout: _scanDuration);
|
|
if (mounted) {
|
|
setState(() {
|
|
_initialScanTriggered = true;
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
super.initState();
|
|
// No animation controllers needed here anymore
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// Dispose controllers if they existed (they don't anymore)
|
|
super.dispose();
|
|
}
|
|
|
|
// Removed _startScanProgressAnimation, _startWaveAnimation, _stopAnimations
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Connect Device'),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () => context.go('/'),
|
|
),
|
|
actions: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 12.0),
|
|
child: Row(
|
|
children: [
|
|
const Text('abawo only'), // Label for the switch
|
|
Switch(
|
|
value: _showOnlyAbawoDevices,
|
|
onChanged: (value) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_showOnlyAbawoDevices = value;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
)
|
|
],
|
|
),
|
|
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),
|
|
),
|
|
),
|
|
// 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()),
|
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
|
data: (controller) {
|
|
// Trigger the initial scan if needed
|
|
_startScanIfNeeded(controller);
|
|
|
|
// StreamBuilder for Scan Results (Device List)
|
|
return StreamBuilder<List<DiscoveredDevice>>(
|
|
stream: controller.scanResultsStream,
|
|
initialData: const [],
|
|
builder: (context, snapshot) {
|
|
final results = snapshot.data ?? [];
|
|
// Filter results based on the toggle state
|
|
final filteredResults = _showOnlyAbawoDevices
|
|
? results
|
|
.where((device) =>
|
|
device.serviceUuids.any(isAbawoDeviceGuid))
|
|
.toList()
|
|
: results;
|
|
|
|
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
|
|
);
|
|
}
|
|
}
|