feat: redesign and lots of progress

This commit is contained in:
2026-04-26 22:43:22 +02:00
parent 16ac66471a
commit 82ea8125e1
24 changed files with 1095 additions and 1315 deletions

View File

@ -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});

View File

@ -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(