From 9622cc2d6465730e66fda279c29d405a8730440f Mon Sep 17 00:00:00 2001 From: JulianAlcala Date: Wed, 22 Apr 2026 02:57:10 +0200 Subject: [PATCH] feat(control-panel): custom animated device dropdown --- .../presentation/control_panel_screen.dart | 218 ++++++++++++++++-- 1 file changed, 197 insertions(+), 21 deletions(-) diff --git a/modules/legacy/modules/control_panel/lib/src/features/control_panel/presentation/control_panel_screen.dart b/modules/legacy/modules/control_panel/lib/src/features/control_panel/presentation/control_panel_screen.dart index 513a4d6e..280e43d4 100644 --- a/modules/legacy/modules/control_panel/lib/src/features/control_panel/presentation/control_panel_screen.dart +++ b/modules/legacy/modules/control_panel/lib/src/features/control_panel/presentation/control_panel_screen.dart @@ -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 devices; + final DeviceEntity? selectedDevice; + final ValueChanged 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 _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(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, + ), + ), + ], + ), + ), + ), + ), + ); + } +}