import 'dart:math'; import 'dart:io' show Platform; import 'package:flame/components.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart' show kIsWeb; /// A joystick thumb that shows movement direction class JoystickThumb extends StatefulWidget { JoystickThumb({ super.key, this.size = 20, Vector2? offset, this.color = Colors.white, }) : offset = offset ?? Vector2.zero(); final double size; final Vector2 offset; final Color color; @override State createState() => _JoystickThumbState(); } class _JoystickThumbState extends State { @override Widget build(BuildContext context) { return Positioned( left: widget.offset.x, top: widget.offset.y, child: Container( width: widget.size, height: widget.size, decoration: BoxDecoration( shape: BoxShape.circle, color: widget.color, border: Border.all(color: Colors.black26, width: 1), ), ), ); } void setOffset(Vector2 offset) { setState(() { widget.offset.setFrom(offset); }); } } /// Virtual joystick for movement class VirtualJoystick extends StatelessWidget { VirtualJoystick({ super.key, required this.onMove, this.size = 80, }); final Function(Vector2) onMove; final double size; final GlobalKey<_JoystickThumbState> _key = GlobalKey(); Vector2 get _centerPosition => Vector2(size / 2 - (size / 6), size / 2 - (size / 6)); @override Widget build(BuildContext context) { return GestureDetector( onPanUpdate: (details) { final offset = details.localPosition; final center = Offset(size / 2, size / 2); final distance = offset - center; final angle = distance.direction; final magnitude = distance.distance; if (magnitude <= size / 2) { final position = Vector2( magnitude * cos(angle), magnitude * sin(angle), // Fixed Y-axis ); // Calculate thumb position relative to center final thumbPos = Vector2( size / 2 + distance.dx - (size / 6), // Center thumb in joystick size / 2 + distance.dy - (size / 6), ); onMove(position * 2 / size); // Normalize to -1 to 1 _key.currentState?.setOffset(thumbPos); } else { final position = Vector2( (size / 2) * cos(angle), (size / 2) * sin(angle), // Fixed Y-axis ); // Calculate thumb position at edge final thumbPos = Vector2( size / 2 + (size / 2) * cos(angle) - (size / 6), size / 2 + (size / 2) * sin(angle) - (size / 6), ); onMove(position * 2 / size); // Normalize to -1 to 1 _key.currentState?.setOffset(thumbPos); } }, onPanCancel: () => { onMove(Vector2.zero()), _key.currentState?.setOffset(_centerPosition), }, onPanEnd: (_) => { onMove(Vector2.zero()), _key.currentState?.setOffset(_centerPosition), }, child: Container( width: size, height: size, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.black.withValues(alpha: 0.3), border: Border.all(color: Colors.white.withValues(alpha: 0.5), width: 2), ), child: Stack( clipBehavior: Clip.none, children: [ JoystickThumb( key: _key, size: size / 3, offset: _centerPosition, // Start at center color: Colors.white.withValues(alpha: 0.8), ), ], ), ), ); } } /// Touch button for actions class TouchButton extends StatefulWidget { const TouchButton({ super.key, required this.onPressed, this.onReleased, required this.icon, this.size = 60, this.color = Colors.white, }); final VoidCallback onPressed; final VoidCallback? onReleased; final IconData icon; final double size; final Color color; @override State createState() => _TouchButtonState(); } class _TouchButtonState extends State { bool _isPressed = false; @override Widget build(BuildContext context) { return GestureDetector( onTapDown: (_) { setState(() => _isPressed = true); widget.onPressed(); HapticFeedback.lightImpact(); }, onTapUp: (_) { setState(() => _isPressed = false); widget.onReleased?.call(); }, onTapCancel: () { setState(() => _isPressed = false); widget.onReleased?.call(); }, child: Container( width: widget.size, height: widget.size, decoration: BoxDecoration( shape: BoxShape.circle, color: _isPressed ? Colors.white.withValues(alpha: 0.8) : Colors.black.withValues(alpha: 0.3), border: Border.all( color: Colors.white.withValues(alpha: 0.5), width: 2, ), ), child: Icon( widget.icon, size: widget.size * 0.4, color: _isPressed ? Colors.black : Colors.white, ), ), ); } } /// Main touch controls overlay class TouchControlsOverlay extends StatelessWidget { const TouchControlsOverlay({ super.key, required this.onMove, required this.onPoopBag, required this.onDoorbell, }); final Function(Vector2) onMove; final VoidCallback onPoopBag; final VoidCallback onDoorbell; @override Widget build(BuildContext context) { return Stack( children: [ // Movement joystick (bottom left) Positioned( left: 20, bottom: 20, child: VirtualJoystick( onMove: onMove, size: 100, ), ), // Action buttons (bottom right) Positioned( right: 20, bottom: 80, child: TouchButton( onPressed: onPoopBag, icon: Icons.eco, // Poop bag icon size: 70, ), ), Positioned( right: 20, bottom: 20, child: TouchButton( onPressed: onDoorbell, icon: Icons.notifications, size: 70, ), ), // Optional: escape timer display when escaping // TODO: Add escape timer UI here ], ); } } /// Utility to detect if device supports touch class TouchDetection { static bool get isTouchDevice { if (kIsWeb) { // On web, assume no touch controls by default return false; } try { return Platform.isAndroid || Platform.isIOS; } catch (e) { // Fallback: assume no touch if platform detection fails return false; } } }