feat: redesign and lots of progress
This commit is contained in:
@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -29,19 +31,36 @@ class BikeScanDialog extends ConsumerStatefulWidget {
|
||||
|
||||
class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||
bool _showAll = false;
|
||||
bool _isStartingScan = true;
|
||||
String? _scanError;
|
||||
BluetoothController? _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startScan();
|
||||
unawaited(_startScan());
|
||||
}
|
||||
|
||||
Future<void> _startScan() async {
|
||||
final controller = await ref.read(bluetoothProvider.future);
|
||||
_controller = controller;
|
||||
await controller.stopScan();
|
||||
await controller.startScan();
|
||||
setState(() {
|
||||
_isStartingScan = true;
|
||||
_scanError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final controller = await ref.read(bluetoothProvider.future);
|
||||
_controller = controller;
|
||||
await controller.stopScan();
|
||||
await controller.startScan();
|
||||
} catch (error) {
|
||||
_scanError = error.toString();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isStartingScan = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@ -73,6 +92,7 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||
children: [
|
||||
_DialogHeader(
|
||||
showAll: _showAll,
|
||||
isScanning: _isStartingScan,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_showAll = value;
|
||||
@ -81,141 +101,163 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||
onRescan: _startScan,
|
||||
),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<DiscoveredDevice>>(
|
||||
stream: controller.scanResultsStream,
|
||||
initialData: controller.scanResults,
|
||||
builder: (context, snapshot) {
|
||||
final devices = _filteredDevices(snapshot.data ?? const []);
|
||||
if (devices.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
child: _scanError != null
|
||||
? _ScanMessage(
|
||||
message: 'Could not start trainer scan: $_scanError',
|
||||
action: TextButton.icon(
|
||||
onPressed: _startScan,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
);
|
||||
}
|
||||
)
|
||||
: StreamBuilder<List<DiscoveredDevice>>(
|
||||
stream: controller.scanResultsStream,
|
||||
initialData: controller.scanResults,
|
||||
builder: (context, snapshot) {
|
||||
if (_isStartingScan &&
|
||||
(snapshot.data == null ||
|
||||
snapshot.data!.isEmpty)) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
itemCount: devices.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final device = devices[index];
|
||||
final isFtms =
|
||||
device.serviceUuids.contains(Uuid.parse(ftmsServiceUuid));
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
onTap: () => Navigator.of(context).pop(device),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
final devices =
|
||||
_filteredDevices(snapshot.data ?? const []);
|
||||
if (devices.isEmpty) {
|
||||
return const _ScanMessage(
|
||||
message:
|
||||
'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.',
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
itemCount: devices.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final device = devices[index];
|
||||
final isFtms = device.serviceUuids
|
||||
.contains(Uuid.parse(ftmsServiceUuid));
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
onTap: () =>
|
||||
Navigator.of(context).pop(device),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.pedal_bike_rounded,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
device.name.isEmpty
|
||||
? 'Unknown Device'
|
||||
: device.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.pedal_bike_rounded,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isFtms ? 'FTMS' : 'Nearby trainer',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
device.name.isEmpty
|
||||
? 'Unknown Device'
|
||||
: device.name,
|
||||
maxLines: 1,
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight:
|
||||
FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isFtms
|
||||
? 'FTMS'
|
||||
: 'Nearby trainer',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
fontWeight:
|
||||
FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
device.id,
|
||||
maxLines: 1,
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(
|
||||
alpha: 0.62),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
device.id,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.62),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.end,
|
||||
children: [
|
||||
_RssiBadge(rssi: device.rssi),
|
||||
const SizedBox(height: 12),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.55),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_RssiBadge(rssi: device.rssi),
|
||||
const SizedBox(height: 12),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.55),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -242,11 +284,13 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||
class _DialogHeader extends StatelessWidget {
|
||||
const _DialogHeader({
|
||||
required this.showAll,
|
||||
required this.isScanning,
|
||||
required this.onChanged,
|
||||
required this.onRescan,
|
||||
});
|
||||
|
||||
final bool showAll;
|
||||
final bool isScanning;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final VoidCallback onRescan;
|
||||
|
||||
@ -265,9 +309,10 @@ class _DialogHeader extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
'Assign Trainer',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
@ -306,10 +351,22 @@ class _DialogHeader extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onRescan,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Rescan'),
|
||||
SizedBox(
|
||||
width: 132,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
),
|
||||
onPressed: isScanning ? null : onRescan,
|
||||
icon: isScanning
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
label: const Text('Rescan'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -319,6 +376,38 @@ class _DialogHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ScanMessage extends StatelessWidget {
|
||||
const _ScanMessage({
|
||||
required this.message,
|
||||
this.action,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(width: 132, child: action!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RssiBadge extends StatelessWidget {
|
||||
const _RssiBadge({required this.rssi});
|
||||
|
||||
|
||||
@ -165,22 +165,7 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
if (_isEditing) ...[
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : _onCancel,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _isSaving ? null : _onSave,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Save'),
|
||||
),
|
||||
] else
|
||||
if (!_isEditing)
|
||||
IconButton(
|
||||
tooltip: 'Edit ratios',
|
||||
onPressed: (widget.isLoading || widget.errorText != null)
|
||||
@ -191,6 +176,11 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isEditing)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
||||
child: _buildEditActions(),
|
||||
),
|
||||
if (widget.isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 24),
|
||||
@ -365,6 +355,9 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
duration: _animDuration,
|
||||
switchInCurve: _animCurve,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
layoutBuilder: (currentChild, previousChildren) {
|
||||
return currentChild ?? const SizedBox.shrink();
|
||||
},
|
||||
transitionBuilder: _snappyTransition,
|
||||
child: Column(
|
||||
key: ValueKey('editors-$_gearLayoutVersion'),
|
||||
@ -373,6 +366,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 4, 14, 14),
|
||||
child: _buildEditActions(),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
@ -381,6 +378,32 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEditActions() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : _onCancel,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(88, 52),
|
||||
),
|
||||
onPressed: _isSaving ? null : _onSave,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Done'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _snappyTransition(Widget child, Animation<double> animation) {
|
||||
final curved = CurvedAnimation(parent: animation, curve: _animCurve);
|
||||
return FadeTransition(
|
||||
|
||||
Reference in New Issue
Block a user