feat: working connection, conn setting, and gear ratio setting for universal shifters
This commit is contained in:
214
lib/widgets/bike_scan_dialog.dart
Normal file
214
lib/widgets/bike_scan_dialog.dart
Normal file
@ -0,0 +1,214 @@
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class BikeScanDialog extends ConsumerStatefulWidget {
|
||||
const BikeScanDialog({
|
||||
required this.excludedDeviceId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String excludedDeviceId;
|
||||
|
||||
static Future<DiscoveredDevice?> show(
|
||||
BuildContext context, {
|
||||
required String excludedDeviceId,
|
||||
}) {
|
||||
return showDialog<DiscoveredDevice>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (_) => BikeScanDialog(excludedDeviceId: excludedDeviceId),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ConsumerState<BikeScanDialog> createState() => _BikeScanDialogState();
|
||||
}
|
||||
|
||||
class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||
bool _showAll = false;
|
||||
BluetoothController? _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startScan();
|
||||
}
|
||||
|
||||
Future<void> _startScan() async {
|
||||
final controller = await ref.read(bluetoothProvider.future);
|
||||
_controller = controller;
|
||||
await controller.stopScan();
|
||||
await controller.startScan();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.stopScan();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final btAsync = ref.watch(bluetoothProvider);
|
||||
|
||||
return Dialog(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: SizedBox(
|
||||
width: 520,
|
||||
height: 520,
|
||||
child: btAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => Center(child: Text('Bluetooth error: $err')),
|
||||
data: (controller) {
|
||||
_controller ??= controller;
|
||||
return Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
const Divider(height: 1),
|
||||
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 Center(
|
||||
child: Text('No matching devices nearby.'),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: devices.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final device = devices[index];
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer,
|
||||
child: const Icon(Icons.pedal_bike),
|
||||
),
|
||||
title: Text(
|
||||
device.name.isEmpty
|
||||
? 'Unknown Device'
|
||||
: device.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
device.id,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: _RssiBadge(rssi: device.rssi),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop(device);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Select Bike',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Text('Show All'),
|
||||
Switch(
|
||||
value: _showAll,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_showAll = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Rescan',
|
||||
onPressed: _startScan,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Close',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<DiscoveredDevice> _filteredDevices(List<DiscoveredDevice> devices) {
|
||||
final ftmsUuid = Uuid.parse(ftmsServiceUuid);
|
||||
return devices.where((device) {
|
||||
if (device.id == widget.excludedDeviceId) {
|
||||
return false;
|
||||
}
|
||||
if (_showAll) {
|
||||
return true;
|
||||
}
|
||||
return device.serviceUuids.contains(ftmsUuid);
|
||||
}).toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
class _RssiBadge extends StatelessWidget {
|
||||
const _RssiBadge({required this.rssi});
|
||||
|
||||
final int rssi;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = rssi > -65
|
||||
? Colors.green
|
||||
: rssi > -80
|
||||
? Colors.orange
|
||||
: Colors.red;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$rssi dBm',
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,23 @@
|
||||
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:ui'; // Required for ImageFilter
|
||||
|
||||
class DeviceListItem extends StatelessWidget {
|
||||
final String deviceName;
|
||||
final String deviceId; // Added for potential future use or subtitle
|
||||
final bool isUnknownDevice;
|
||||
final DeviceType type;
|
||||
// final String? imageUrl; // Optional image URL - commented out for now
|
||||
final bool isConnecting; // Add this line
|
||||
final Widget? trailing;
|
||||
|
||||
const DeviceListItem({
|
||||
super.key,
|
||||
required this.deviceName,
|
||||
required this.deviceId,
|
||||
this.isUnknownDevice = false,
|
||||
required this.type,
|
||||
// this.imageUrl,
|
||||
this.isConnecting = false, // Add this line
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -22,11 +27,11 @@ class DeviceListItem extends StatelessWidget {
|
||||
|
||||
// Glassy effect colors - adjust transparency and base color as needed
|
||||
final glassColor = isDarkMode
|
||||
? Colors.white.withOpacity(0.1)
|
||||
: Colors.black.withOpacity(0.05);
|
||||
? Colors.white.withValues(alpha: 0.1)
|
||||
: Colors.black.withValues(alpha: 0.05);
|
||||
final shadowColor = isDarkMode
|
||||
? Colors.black.withOpacity(0.4)
|
||||
: Colors.grey.withOpacity(0.5);
|
||||
? Colors.black.withValues(alpha: 0.4)
|
||||
: Colors.grey.withValues(alpha: 0.5);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
@ -57,21 +62,33 @@ class DeviceListItem extends StatelessWidget {
|
||||
glassColor, // Semi-transparent color for glass effect
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.2), // Subtle border
|
||||
color:
|
||||
Colors.white.withValues(alpha: 0.2), // Subtle border
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
// Placeholder '?' - replace with Image widget when imageUrl is available
|
||||
child: Text(
|
||||
'?',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white70, // Adjust color as needed
|
||||
),
|
||||
),
|
||||
),
|
||||
child: type == DeviceType.universalShifters
|
||||
// For Universal Shifters: Image fills the container, constrained by rounded borders
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.asset(
|
||||
'assets/images/shifter-wireframe.png',
|
||||
fit: BoxFit.cover, // Cover the entire container
|
||||
width: 60,
|
||||
height: 60,
|
||||
),
|
||||
)
|
||||
// For other devices: Question mark with padding
|
||||
: const Center(
|
||||
child: Text(
|
||||
'?',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -84,14 +101,16 @@ class DeviceListItem extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isUnknownDevice ? 'Unknown Device' : deviceName,
|
||||
deviceName.isEmpty ? 'Unknown Device' : deviceName,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight:
|
||||
isUnknownDevice ? FontWeight.normal : FontWeight.w500,
|
||||
fontStyle:
|
||||
isUnknownDevice ? FontStyle.italic : FontStyle.normal,
|
||||
color: isUnknownDevice
|
||||
fontWeight: deviceName.isEmpty
|
||||
? FontWeight.normal
|
||||
: FontWeight.w500,
|
||||
fontStyle: deviceName.isEmpty
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
color: deviceName.isEmpty
|
||||
? theme.hintColor
|
||||
: theme.textTheme.bodyLarge?.color,
|
||||
),
|
||||
@ -108,6 +127,19 @@ class DeviceListItem extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Add spinner if connecting (Add this block)
|
||||
if (isConnecting)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0), // Add some spacing
|
||||
child: SizedBox(
|
||||
width: 20, // Define spinner size
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.0), // Use a small spinner
|
||||
),
|
||||
)
|
||||
else if (trailing != null)
|
||||
trailing!,
|
||||
// Optional: Add an icon or button on the far right if needed later
|
||||
// Icon(Icons.chevron_right, color: theme.hintColor),
|
||||
],
|
||||
|
||||
892
lib/widgets/gear_ratio_editor_card.dart
Normal file
892
lib/widgets/gear_ratio_editor_card.dart
Normal file
@ -0,0 +1,892 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GearRatioPreset {
|
||||
const GearRatioPreset({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.ratios,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String description;
|
||||
final List<double> ratios;
|
||||
}
|
||||
|
||||
class GearRatioEditorCard extends StatefulWidget {
|
||||
const GearRatioEditorCard({
|
||||
required this.ratios,
|
||||
required this.isLoading,
|
||||
required this.onSave,
|
||||
required this.presets,
|
||||
this.errorText,
|
||||
this.onRetry,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final List<double> ratios;
|
||||
final bool isLoading;
|
||||
final Future<String?> Function(List<double> ratios) onSave;
|
||||
final List<GearRatioPreset> presets;
|
||||
final String? errorText;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
State<GearRatioEditorCard> createState() => _GearRatioEditorCardState();
|
||||
}
|
||||
|
||||
class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
static const double _sliderMin = 0.10;
|
||||
static const double _sliderMax = 3.90;
|
||||
static const double _sliderPivotT = 0.50;
|
||||
static const double _sliderPivotV = 1.00;
|
||||
static const Duration _animDuration = Duration(milliseconds: 280);
|
||||
static const Curve _animCurve = Cubic(0.2, 0.8, 0.2, 1.0);
|
||||
|
||||
bool _isExpanded = false;
|
||||
bool _isEditing = false;
|
||||
bool _sortAscending = true;
|
||||
bool _isSaving = false;
|
||||
double _stretchFactor = 1.0;
|
||||
List<double>? _stretchBase;
|
||||
int _gearLayoutVersion = 0;
|
||||
|
||||
List<double> _committed = const [];
|
||||
List<double> _draft = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_committed = List<double>.from(widget.ratios);
|
||||
_draft = List<double>.from(widget.ratios);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant GearRatioEditorCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!_isEditing && !_listEquals(oldWidget.ratios, widget.ratios)) {
|
||||
_committed = List<double>.from(widget.ratios);
|
||||
_draft = List<double>.from(widget.ratios);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color:
|
||||
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
child: AnimatedSize(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 10, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Gear Ratios',
|
||||
style:
|
||||
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
|
||||
IconButton(
|
||||
tooltip: 'Edit ratios',
|
||||
onPressed: (widget.isLoading || widget.errorText != null)
|
||||
? null
|
||||
: _enterEditMode,
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (widget.errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.errorText!,
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
if (widget.onRetry != null)
|
||||
TextButton.icon(
|
||||
onPressed: widget.onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (_committed.isEmpty && !_isEditing)
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 8, 16, 18),
|
||||
child: Text('No gear ratios found on device.'),
|
||||
)
|
||||
else ...[
|
||||
if ((_isEditing ? _draft : _committed).isNotEmpty)
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: _isEditing
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_isExpanded = !_isExpanded;
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 2, 14, 8),
|
||||
child: AnimatedContainer(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
height: _isExpanded ? 210 : 130,
|
||||
child: _GearRatioGraph(
|
||||
ratios: _isEditing ? _draft : _committed,
|
||||
compact: !_isExpanded,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isExpanded && !_isEditing && _committed.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
|
||||
child: _compactRatioStrip(context, _committed),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _isExpanded && !_isEditing
|
||||
? Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var i = 0; i < _committed.length; i++)
|
||||
_ratioChip(context, i + 1, _committed[i]),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
if (_isEditing) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Sort ascending'),
|
||||
Switch(
|
||||
value: _sortAscending,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_sortAscending = value;
|
||||
if (_sortAscending) {
|
||||
_sortDraft(animate: true);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _openPresetPicker,
|
||||
icon: const Icon(Icons.tune),
|
||||
label: const Text('Load preset'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_draft.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(14, 0, 14, 10),
|
||||
child:
|
||||
Text('No ratios yet. Load a preset to start editing.'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Stretch all: ${_stretchFactor.toStringAsFixed(2)}x',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
Slider(
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
value: _stretchToSlider(_stretchFactor),
|
||||
onChangeStart: (_) {
|
||||
_stretchBase = List<double>.from(_draft);
|
||||
},
|
||||
onChanged: (value) {
|
||||
final factor = _sliderToStretch(value);
|
||||
final base = _stretchBase ?? _draft;
|
||||
setState(() {
|
||||
_stretchFactor = factor;
|
||||
_draft = base
|
||||
.map((ratio) => _quantizeRatio(ratio * factor))
|
||||
.toList(growable: false);
|
||||
});
|
||||
},
|
||||
onChangeEnd: (_) {
|
||||
setState(() {
|
||||
if (_sortAscending) {
|
||||
_sortDraft(animate: true);
|
||||
}
|
||||
_stretchBase = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 12),
|
||||
child: AnimatedSwitcher(
|
||||
duration: _animDuration,
|
||||
switchInCurve: _animCurve,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: _snappyTransition,
|
||||
child: Wrap(
|
||||
key: ValueKey('editors-$_gearLayoutVersion'),
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var i = 0; i < _draft.length; i++)
|
||||
KeyedSubtree(
|
||||
key: ValueKey('editor-${i + 1}'),
|
||||
child: _buildGearEditor(context, i),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _snappyTransition(Widget child, Animation<double> animation) {
|
||||
final curved = CurvedAnimation(parent: animation, curve: _animCurve);
|
||||
return FadeTransition(
|
||||
opacity: curved,
|
||||
child: SizeTransition(
|
||||
sizeFactor: curved,
|
||||
axisAlignment: -1,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGearEditor(BuildContext context, int index) {
|
||||
final ratio = _draft[index];
|
||||
final sliderValue = _valueToSlider(ratio);
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 230, maxWidth: 280),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.7),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Gear ${index + 1}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Spacer(),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: () => _editRatioText(index),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
ratio.toStringAsFixed(2),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
min: 0,
|
||||
max: 1,
|
||||
value: sliderValue,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_draft[index] = _quantizeRatio(_sliderToValue(value));
|
||||
});
|
||||
},
|
||||
onChangeEnd: (_) {
|
||||
setState(() {
|
||||
if (_sortAscending) {
|
||||
_sortDraft(animate: true);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _editRatioText(int index) async {
|
||||
final controller = TextEditingController(
|
||||
text: _draft[index].toStringAsFixed(2),
|
||||
);
|
||||
|
||||
final value = await showDialog<double>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text('Set gear ${index + 1} ratio'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(hintText: 'e.g. 1.25'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final parsed = double.tryParse(controller.text.trim());
|
||||
Navigator.of(context).pop(parsed);
|
||||
},
|
||||
child: const Text('Set'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (!mounted || value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_draft[index] = _quantizeRatio(value);
|
||||
if (_sortAscending) {
|
||||
_sortDraft(animate: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openPresetPicker() async {
|
||||
final selected = await showModalBottomSheet<GearRatioPreset>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 12),
|
||||
child: ListView.separated(
|
||||
itemCount: widget.presets.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final preset = widget.presets[index];
|
||||
return Material(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () => Navigator.of(context).pop(preset),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
preset.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
preset.description,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
height: 90,
|
||||
child: _GearRatioGraph(
|
||||
ratios: preset.ratios, compact: true),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_compactRatioStrip(
|
||||
context,
|
||||
preset.ratios,
|
||||
showGearLabel: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (!mounted || selected == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_draft = selected.ratios.map(_quantizeRatio).toList(growable: false);
|
||||
if (_sortAscending) {
|
||||
_sortDraft(animate: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _sortDraft({bool animate = false}) {
|
||||
final sorted = _sorted(_draft);
|
||||
if (animate && !_listEquals(_draft, sorted)) {
|
||||
_gearLayoutVersion++;
|
||||
}
|
||||
_draft = sorted;
|
||||
}
|
||||
|
||||
void _enterEditMode() {
|
||||
setState(() {
|
||||
_isEditing = true;
|
||||
_isExpanded = true;
|
||||
_stretchFactor = 1.0;
|
||||
_stretchBase = null;
|
||||
_draft = List<double>.from(_committed);
|
||||
});
|
||||
}
|
||||
|
||||
void _onCancel() {
|
||||
setState(() {
|
||||
_isEditing = false;
|
||||
_draft = List<double>.from(_committed);
|
||||
_stretchFactor = 1.0;
|
||||
_stretchBase = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onSave() async {
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
});
|
||||
|
||||
final message = await widget.onSave(List<double>.from(_draft));
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
if (message == null) {
|
||||
_committed = List<double>.from(_draft);
|
||||
_isEditing = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (message != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _ratioChip(BuildContext context, int gear, double ratio) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
color: theme.colorScheme.surface.withValues(alpha: 0.7),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
child: Text('G$gear ${ratio.toStringAsFixed(2)}'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _compactRatioStrip(
|
||||
BuildContext context,
|
||||
List<double> ratios, {
|
||||
bool showGearLabel = true,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return SizedBox(
|
||||
height: 26,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < ratios.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: i == ratios.length - 1 ? 0 : 6),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
color: theme.colorScheme.surface.withValues(alpha: 0.7),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant
|
||||
.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
showGearLabel
|
||||
? 'G${i + 1} ${ratios[i].toStringAsFixed(2)}'
|
||||
: ratios[i].toStringAsFixed(2),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _quantizeRatio(double raw) {
|
||||
final clamped = raw.clamp(_sliderMin, _sliderMax);
|
||||
return ((clamped * 64).round() / 64.0).clamp(_sliderMin, _sliderMax);
|
||||
}
|
||||
|
||||
List<double> _sorted(List<double> values) {
|
||||
final out = List<double>.from(values)..sort();
|
||||
return out;
|
||||
}
|
||||
|
||||
bool _listEquals(List<double> a, List<double> b) {
|
||||
if (a.length != b.length) {
|
||||
return false;
|
||||
}
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
double _sliderToValue(double t) {
|
||||
final normalized = t.clamp(0.0, 1.0);
|
||||
if (normalized <= _sliderPivotT) {
|
||||
final u = normalized / _sliderPivotT;
|
||||
return _sliderMin * math.pow(_sliderPivotV / _sliderMin, u);
|
||||
}
|
||||
final u = (normalized - _sliderPivotT) / (1 - _sliderPivotT);
|
||||
return _sliderPivotV * math.pow(_sliderMax / _sliderPivotV, u);
|
||||
}
|
||||
|
||||
double _valueToSlider(double value) {
|
||||
final clamped = value.clamp(_sliderMin, _sliderMax);
|
||||
if (clamped <= _sliderPivotV) {
|
||||
final u =
|
||||
math.log(clamped / _sliderMin) / math.log(_sliderPivotV / _sliderMin);
|
||||
return (u * _sliderPivotT).clamp(0.0, 1.0);
|
||||
}
|
||||
final u = math.log(clamped / _sliderPivotV) /
|
||||
math.log(_sliderMax / _sliderPivotV);
|
||||
return (_sliderPivotT + u * (1 - _sliderPivotT)).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
double _sliderToStretch(double t) {
|
||||
return (0.6 + (1.6 - 0.6) * t).clamp(0.6, 1.6);
|
||||
}
|
||||
|
||||
double _stretchToSlider(double factor) {
|
||||
return ((factor - 0.6) / (1.6 - 0.6)).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
class _GearRatioGraph extends StatelessWidget {
|
||||
const _GearRatioGraph({required this.ratios, required this.compact});
|
||||
|
||||
final List<double> ratios;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.65),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!compact)
|
||||
Text(
|
||||
'Input RPM -> Output RPM',
|
||||
style: textTheme.bodySmall,
|
||||
),
|
||||
Expanded(
|
||||
child: CustomPaint(
|
||||
painter: _GearRatioGraphPainter(
|
||||
ratios: ratios,
|
||||
axisColor: Theme.of(context).colorScheme.outline,
|
||||
lineColor: Theme.of(context).colorScheme.primary,
|
||||
textColor: Theme.of(context).colorScheme.onSurface,
|
||||
compact: compact,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GearRatioGraphPainter extends CustomPainter {
|
||||
const _GearRatioGraphPainter({
|
||||
required this.ratios,
|
||||
required this.axisColor,
|
||||
required this.lineColor,
|
||||
required this.textColor,
|
||||
required this.compact,
|
||||
});
|
||||
|
||||
final List<double> ratios;
|
||||
final Color axisColor;
|
||||
final Color lineColor;
|
||||
final Color textColor;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (ratios.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const left = 28.0;
|
||||
const right = 10.0;
|
||||
const top = 8.0;
|
||||
const bottom = 24.0;
|
||||
final chart = Rect.fromLTWH(
|
||||
left,
|
||||
top,
|
||||
size.width - left - right,
|
||||
size.height - top - bottom,
|
||||
);
|
||||
|
||||
final axisPaint = Paint()
|
||||
..color = axisColor.withValues(alpha: 0.55)
|
||||
..strokeWidth = 1.2;
|
||||
canvas.drawRect(chart, axisPaint..style = PaintingStyle.stroke);
|
||||
|
||||
final maxRatio = ratios.reduce(math.max);
|
||||
final xMax = 120.0;
|
||||
final computedYMax = (xMax * math.max(maxRatio, 1.0)).toDouble();
|
||||
final yMax = math.min(400.0, computedYMax);
|
||||
|
||||
for (var i = 1; i <= 3; i++) {
|
||||
final y = chart.bottom - (chart.height * (i / 4));
|
||||
canvas.drawLine(
|
||||
Offset(chart.left, y),
|
||||
Offset(chart.right, y),
|
||||
axisPaint..color = axisColor.withValues(alpha: 0.2),
|
||||
);
|
||||
}
|
||||
|
||||
for (var i = 1; i <= 3; i++) {
|
||||
final x = chart.left + (chart.width * (i / 4));
|
||||
canvas.drawLine(
|
||||
Offset(x, chart.top),
|
||||
Offset(x, chart.bottom),
|
||||
axisPaint..color = axisColor.withValues(alpha: 0.15),
|
||||
);
|
||||
}
|
||||
|
||||
for (var i = 0; i < ratios.length; i++) {
|
||||
final ratio = ratios[i];
|
||||
final p = i / math.max(1, ratios.length - 1);
|
||||
final color = Color.lerp(
|
||||
lineColor.withValues(alpha: 0.40),
|
||||
lineColor,
|
||||
p,
|
||||
)!;
|
||||
|
||||
final endYValue = xMax * ratio;
|
||||
final isClipped = endYValue > yMax;
|
||||
final endX = isClipped
|
||||
? chart.left + chart.width * (yMax / endYValue)
|
||||
: chart.right;
|
||||
final endY = isClipped
|
||||
? chart.top
|
||||
: chart.bottom - (endYValue / yMax) * chart.height;
|
||||
|
||||
final linePaint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = 2;
|
||||
|
||||
canvas.drawLine(
|
||||
Offset(chart.left, chart.bottom),
|
||||
Offset(endX, endY),
|
||||
linePaint,
|
||||
);
|
||||
|
||||
if (!compact && i % 2 == 0) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: 'G${i + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 10, color: textColor.withValues(alpha: 0.75)),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
tp.paint(
|
||||
canvas,
|
||||
Offset(
|
||||
(endX - tp.width - 2).clamp(chart.left, chart.right - tp.width),
|
||||
(endY - 10).clamp(chart.top, chart.bottom - tp.height),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final xLabel = TextPainter(
|
||||
text: TextSpan(
|
||||
text: 'In RPM',
|
||||
style:
|
||||
TextStyle(fontSize: 10, color: textColor.withValues(alpha: 0.75)),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
xLabel.paint(canvas, Offset(chart.right - xLabel.width, chart.bottom + 17));
|
||||
|
||||
final xMinValue = TextPainter(
|
||||
text: TextSpan(
|
||||
text: '0',
|
||||
style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
xMinValue.paint(canvas, Offset(chart.left, chart.bottom + 5));
|
||||
|
||||
final xMaxValue = TextPainter(
|
||||
text: TextSpan(
|
||||
text: xMax.toStringAsFixed(0),
|
||||
style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
xMaxValue.paint(
|
||||
canvas,
|
||||
Offset(chart.right - xMaxValue.width, chart.bottom + 5),
|
||||
);
|
||||
|
||||
final yLabel = TextPainter(
|
||||
text: TextSpan(
|
||||
text: 'Out RPM',
|
||||
style:
|
||||
TextStyle(fontSize: 10, color: textColor.withValues(alpha: 0.75)),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
yLabel.paint(canvas, Offset(chart.left - 28, chart.top - 14));
|
||||
|
||||
final yMinValue = TextPainter(
|
||||
text: TextSpan(
|
||||
text: '0',
|
||||
style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
yMinValue.paint(
|
||||
canvas,
|
||||
Offset(chart.left - yMinValue.width - 4, chart.bottom - yMinValue.height),
|
||||
);
|
||||
|
||||
final yMaxValue = TextPainter(
|
||||
text: TextSpan(
|
||||
text: yMax.toStringAsFixed(0),
|
||||
style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
yMaxValue.paint(
|
||||
canvas,
|
||||
Offset(chart.left - yMaxValue.width - 4, chart.top),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _GearRatioGraphPainter oldDelegate) {
|
||||
if (oldDelegate.compact != compact ||
|
||||
oldDelegate.ratios.length != ratios.length) {
|
||||
return true;
|
||||
}
|
||||
for (var i = 0; i < ratios.length; i++) {
|
||||
if (ratios[i] != oldDelegate.ratios[i]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
207
lib/widgets/horizontal_scanning_animation.dart
Normal file
207
lib/widgets/horizontal_scanning_animation.dart
Normal file
@ -0,0 +1,207 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class HorizontalScanningAnimation extends StatefulWidget {
|
||||
final bool isScanning; // Add this to control the animation
|
||||
final Color waveColor;
|
||||
final double height;
|
||||
const HorizontalScanningAnimation({
|
||||
super.key,
|
||||
required this.isScanning, // Make it required
|
||||
this.waveColor = Colors.lightBlueAccent,
|
||||
this.height = 50.0,
|
||||
});
|
||||
@override
|
||||
_HorizontalScanningAnimationState createState() =>
|
||||
_HorizontalScanningAnimationState();
|
||||
}
|
||||
|
||||
class _HorizontalScanningAnimationState
|
||||
extends State<HorizontalScanningAnimation>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
// Start repeating only if initially scanning
|
||||
if (widget.isScanning) {
|
||||
_controller.repeat();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant HorizontalScanningAnimation oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.isScanning != oldWidget.isScanning) {
|
||||
if (widget.isScanning) {
|
||||
// Start or resume repeating
|
||||
if (!_controller.isAnimating) {
|
||||
// If stopped previously, reset before repeating for a clean start
|
||||
// Though, repeat() should handle restarting if stopped. Testing needed.
|
||||
// _controller.reset(); // Optional: uncomment if repeat doesn't restart smoothly
|
||||
_controller.repeat();
|
||||
}
|
||||
} else {
|
||||
// Stop repeating, but let the current animation cycle finish visually
|
||||
if (_controller.isAnimating) {
|
||||
_controller.stop(
|
||||
canceled:
|
||||
false); // Use canceled: false to let it finish the current tick
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Only build the painter if the controller is active or was recently stopped
|
||||
// This prevents drawing when completely idle. Check if value is changing or non-zero.
|
||||
// Or simply rely on the AnimatedBuilder which won't rebuild if controller is idle at 0.0
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
width: double.infinity,
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return CustomPaint(
|
||||
painter: _HorizontalWavePainter(
|
||||
progress: _controller.value,
|
||||
waveColor: widget.waveColor,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HorizontalWavePainter extends CustomPainter {
|
||||
final double progress; // Animation value from 0.0 to 1.0
|
||||
final Color waveColor;
|
||||
final int waveCount = 2; // Number of waves visible at once
|
||||
final double waveAmplitude = 10.0; // Max height deviation of the wave
|
||||
|
||||
_HorizontalWavePainter({required this.progress, required this.waveColor});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = waveColor.withValues(alpha: 0.6) // Semi-transparent waves
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final centerY = size.height / 2;
|
||||
final width = size.width;
|
||||
|
||||
// Draw multiple waves propagating outwards
|
||||
for (int i = 0; i < waveCount; i++) {
|
||||
// Calculate the phase offset for each wave based on progress and index
|
||||
// This creates the effect of waves moving outwards
|
||||
double waveProgress = (progress + i / waveCount) % 1.0;
|
||||
|
||||
// Use an easing curve for smoother expansion
|
||||
double easedProgress = Curves.easeInOutSine.transform(waveProgress);
|
||||
|
||||
// Calculate the current horizontal position (expanding from center)
|
||||
// The wave starts narrow and expands outwards
|
||||
double currentWidth =
|
||||
width * easedProgress * 0.8; // Max 80% width expansion
|
||||
double startX = (width / 2) - (currentWidth / 2);
|
||||
double endX = (width / 2) + (currentWidth / 2);
|
||||
|
||||
// Calculate opacity based on progress (fade in and out)
|
||||
double opacity;
|
||||
if (waveProgress < 0.1) {
|
||||
opacity = waveProgress / 0.1; // Fade in
|
||||
} else if (waveProgress > 0.8) {
|
||||
opacity = (1.0 - waveProgress) / 0.2; // Fade out
|
||||
} else {
|
||||
opacity = 1.0;
|
||||
}
|
||||
opacity = max(0.0, opacity); // Clamp opacity
|
||||
|
||||
if (opacity <= 0.0 || currentWidth < 5)
|
||||
continue; // Skip drawing if invisible or too small
|
||||
|
||||
// Create the wave path
|
||||
final path = Path();
|
||||
path.moveTo(startX, centerY);
|
||||
|
||||
// Calculate points for the sine wave shape within the current width
|
||||
const int segments = 50; // Number of segments for the curve
|
||||
for (int j = 0; j <= segments; j++) {
|
||||
double segmentProgress = j / segments;
|
||||
double x = startX + currentWidth * segmentProgress;
|
||||
// Apply sine wave based on segment progress and overall animation progress
|
||||
// Multiply by (1 - easedProgress) to reduce amplitude as it expands
|
||||
double yOffset = waveAmplitude *
|
||||
sin(segmentProgress * 2 * pi + progress * 4 * pi) *
|
||||
(1 - easedProgress * 0.8) * // Reduce amplitude as it expands
|
||||
opacity; // Apply opacity effect to amplitude too
|
||||
path.lineTo(x, centerY + yOffset);
|
||||
}
|
||||
|
||||
// Draw a filled shape (like a lens flare or horizontal bar)
|
||||
// Adjust thickness based on easedProgress (thicker in the middle, thinner at ends)
|
||||
double thickness =
|
||||
waveAmplitude * (1 - easedProgress * 0.9) * opacity * 0.5;
|
||||
paint.color = waveColor.withValues(
|
||||
alpha: opacity * 0.5); // Update paint color with opacity
|
||||
|
||||
// Simplified: Draw a rectangle that pulses
|
||||
// More complex shapes could be drawn here using path.arcTo or path.quadraticBezierTo
|
||||
// For simplicity, let's use a slightly blurred rectangle effect
|
||||
|
||||
final rectPath = Path()
|
||||
..addRRect(RRect.fromRectAndRadius(
|
||||
Rect.fromCenter(
|
||||
center: Offset(width / 2, centerY),
|
||||
width: currentWidth,
|
||||
height: thickness * 2),
|
||||
Radius.circular(thickness)));
|
||||
|
||||
// Apply a blur effect
|
||||
final blurPaint = Paint()
|
||||
..color = waveColor.withValues(alpha: opacity * 0.4)
|
||||
..maskFilter = MaskFilter.blur(
|
||||
BlurStyle.normal, thickness * 1.5); // Blur based on thickness
|
||||
|
||||
// Draw the blurred shape
|
||||
canvas.drawPath(rectPath, blurPaint);
|
||||
|
||||
// Draw a slightly smaller, less opaque shape on top for highlight
|
||||
final highlightPaint = Paint()
|
||||
..color = waveColor.withValues(alpha: opacity * 0.7)
|
||||
..style = PaintingStyle.fill;
|
||||
final highlightRectPath = Path()
|
||||
..addRRect(RRect.fromRectAndRadius(
|
||||
Rect.fromCenter(
|
||||
center: Offset(width / 2, centerY),
|
||||
width: currentWidth * 0.95,
|
||||
height: thickness * 1.5),
|
||||
Radius.circular(thickness * 0.8)));
|
||||
canvas.drawPath(highlightRectPath, highlightPaint);
|
||||
|
||||
// Old Path drawing - keep if rectangle isn't desired
|
||||
// paint.color = waveColor.withValues(alpha: opacity * 0.5); // Apply opacity
|
||||
// canvas.drawPath(path, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _HorizontalWavePainter oldDelegate) {
|
||||
// Repaint whenever the animation progress or color changes
|
||||
return oldDelegate.progress != progress ||
|
||||
oldDelegate.waveColor != waveColor;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user