feat(control-panel): custom animated device dropdown

This commit is contained in:
2026-04-22 02:57:10 +02:00
parent 79e8c0fe74
commit 9622cc2d64

View File

@@ -120,27 +120,10 @@ class _Header extends ConsumerWidget {
height: SizeUtils.getByScreen(small: 18, big: 18),
),
SizedBox(width: SizeUtils.getByScreen(small: 8, big: 4)),
SizedBox(
width: SizeUtils.getByScreen(small: 100, big: 110),
height: 32,
child: CustomDropdown(
items: state.devices.map((DeviceEntity device) {
return Text(
device.carrierName ?? '',
overflow: TextOverflow.ellipsis,
maxLines: 1,
);
}).toList(),
values: state.devices,
value: state.devices.where((d) => d.id == state.selectedDevice?.id).firstOrNull,
onChanged: (device) {
vm.setSelectedDevice(device);
},
height: 32,
color: Colors.transparent,
padding: EdgeInsets.zero,
showIcon: false,
),
_DeviceDropdown(
devices: state.devices,
selectedDevice: state.selectedDevice,
onChanged: vm.setSelectedDevice,
),
],
),
@@ -316,3 +299,196 @@ class _MapSection extends ConsumerWidget {
);
}
}
class _DeviceDropdown extends StatefulWidget {
final List<DeviceEntity> devices;
final DeviceEntity? selectedDevice;
final ValueChanged<DeviceEntity> onChanged;
const _DeviceDropdown({
required this.devices,
required this.selectedDevice,
required this.onChanged,
});
@override
State<_DeviceDropdown> createState() => _DeviceDropdownState();
}
class _DeviceDropdownState extends State<_DeviceDropdown>
with SingleTickerProviderStateMixin {
final _overlayController = OverlayPortalController();
final _link = LayerLink();
late final AnimationController _animation;
late final Animation<double> _curve;
@override
void initState() {
super.initState();
_animation = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_curve = CurvedAnimation(
parent: _animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
}
@override
void dispose() {
_animation.dispose();
super.dispose();
}
void _toggle() {
if (_overlayController.isShowing) {
_animation.reverse().then((_) {
if (mounted) _overlayController.hide();
});
} else {
_overlayController.show();
_animation.forward();
}
}
void _select(DeviceEntity device) {
widget.onChanged(device);
_animation.reverse().then((_) {
if (mounted) _overlayController.hide();
});
}
@override
Widget build(BuildContext context) {
final primaryColor = Theme.of(context).primaryColor;
final selectedName = widget.selectedDevice?.carrierName ?? '';
return CompositedTransformTarget(
link: _link,
child: OverlayPortal(
controller: _overlayController,
overlayChildBuilder: (_) => GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _toggle,
child: SizedBox.expand(
child: Align(
alignment: Alignment.topLeft,
child: CompositedTransformFollower(
link: _link,
targetAnchor: Alignment.bottomLeft,
followerAnchor: Alignment.topLeft,
offset: const Offset(0, 4),
child: FadeTransition(
opacity: _curve,
child: ScaleTransition(
scale: Tween<double>(begin: 0.92, end: 1.0)
.animate(_curve),
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(12),
color: Theme.of(context).colorScheme.surface,
child: IntrinsicWidth(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: widget.devices.map((device) {
final isSelected =
device.id == widget.selectedDevice?.id;
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => _select(device),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.watch,
size: 16,
color: isSelected
? primaryColor
: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.4),
),
const SizedBox(width: 8),
Text(
device.carrierName ?? '',
style: TextStyle(
fontSize: 13,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w400,
color: isSelected
? primaryColor
: Theme.of(context)
.colorScheme
.onSurface,
),
),
],
),
),
);
}).toList(),
),
),
),
),
),
),
),
),
),
),
child: GestureDetector(
onTap: widget.devices.length > 1 ? _toggle : null,
child: SizedBox(
height: 32,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: SizeUtils.getByScreen(small: 100, big: 110),
),
child: Text(
selectedName,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 12, big: 13),
fontWeight: FontWeight.w600,
color: primaryColor,
),
),
),
if (widget.devices.length > 1)
AnimatedBuilder(
animation: _animation,
builder: (context, child) => Transform.rotate(
angle: _curve.value * 3.14159,
child: child,
),
child: Icon(
Icons.keyboard_arrow_down,
size: 18,
color: primaryColor,
),
),
],
),
),
),
),
);
}
}