diff --git a/lib/main.dart b/lib/main.dart index 9bef2d4..ff29f76 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:convert'; // jsonEncode 등을 위해 필요할 수 있음 (bleService 내부에서 처리하지만 안전하게) +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:ambient_node/screens/splash_screen.dart'; import 'package:ambient_node/screens/dashboard_screen.dart'; @@ -10,8 +10,6 @@ import 'package:ambient_node/screens/settings_screen.dart'; import 'package:ambient_node/services/analytics_service.dart'; import 'package:ambient_node/services/ble_service.dart'; -class AiService {} - void main() { runApp(const MyApp()); } @@ -45,9 +43,7 @@ class _SplashWrapperState extends State { @override Widget build(BuildContext context) { - if (_showMain) { - return const MainShell(); - } + if (_showMain) return const MainShell(); return SplashScreen( onFinish: () { @@ -81,6 +77,7 @@ class _MainShellState extends State { String? selectedUserName; String? selectedUserImagePath; + String? selectedUserId; // userId 저장 추가 @override void initState() { @@ -130,37 +127,34 @@ class _MainShellState extends State { print('[Main] 연결 해제 오류: $e'); } } else { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => DeviceSelectionScreen( - bleService: ble, - onConnectionChanged: (isConnected) { - // 연결 성공 시 초기 상태 전송은 하지 않음 (사용자가 조작할 때 전송) - }, - onDeviceNameChanged: (name) { - if (mounted) setState(() => deviceName = name); - }, - ), + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => DeviceSelectionScreen( + bleService: ble, + onConnectionChanged: (isConnected) {}, + onDeviceNameChanged: (name) { + if (mounted) setState(() => deviceName = name); + }, ), - ); + )); } } void _showSnackBar(String message) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message), duration: const Duration(seconds: 2))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 2)), + ); } - // [수정됨] 1. 풍속 변경 전용 함수 (Action 포함) + // 대시보드에서 속도 변경 시 호출 void _sendSpeedChange(int newSpeed) { if (!connected) return; - // 안전장치: 0~5 사이로 강제 변환 int targetSpeed = newSpeed.clamp(0, 5); - final data = { - 'action': 'speed_change', // Gateway가 인식하는 필수 키 + 'action': 'speed_change', 'speed': targetSpeed, + 'user_list': _getCurrentUserList(), 'timestamp': DateTime.now().toIso8601String(), }; @@ -172,13 +166,14 @@ class _MainShellState extends State { } } - // [수정됨] 2. 모드(트래킹) 변경 전용 함수 (Action 포함) + // 대시보드에서 모드 변경 시 호출 void _sendModeChange(bool isAiMode) { if (!connected) return; final data = { - 'action': 'mode_change', // Gateway가 인식하는 필수 키 + 'action': 'mode_change', 'mode': isAiMode ? 'ai' : 'manual', + 'user_list': _getCurrentUserList(), 'timestamp': DateTime.now().toIso8601String(), }; @@ -190,61 +185,72 @@ class _MainShellState extends State { } } + // 현재 선택된 사용자 정보를 user_list 형식으로 반환하는 헬퍼 + List> _getCurrentUserList() { + if (selectedUserId == null || selectedUserName == null) return []; + + return [ + { + 'user_id': selectedUserId, + 'username': selectedUserName, + 'role': 1, + }, + ]; + } + @override Widget build(BuildContext context) { final screens = [ DashboardScreen( connected: connected, onConnect: handleConnect, - speed: speed, - // [중요] setSpeed에서 _sendSpeedChange 호출 setSpeed: (v) { setState(() => speed = v); _sendSpeedChange(v); - try { AnalyticsService.onSpeedChanged(v); } catch (e) {} + try { + AnalyticsService.onSpeedChanged(v); + } catch (e) {} }, - trackingOn: trackingOn, - // [중요] setTrackingOn에서 _sendModeChange 호출 setTrackingOn: (v) { setState(() => trackingOn = v); _sendModeChange(v); - try { v ? AnalyticsService.onFaceTrackingStart() : AnalyticsService.onFaceTrackingStop(); } catch (e) {} + try { + v ? AnalyticsService.onFaceTrackingStart() : AnalyticsService.onFaceTrackingStop(); + } catch (e) {} }, - openAnalytics: () => setState(() => _index = 2), deviceName: deviceName, selectedUserName: selectedUserName, selectedUserImagePath: selectedUserImagePath, ), - ControlScreen( connected: connected, deviceName: deviceName, onConnect: handleConnect, dataStream: _bleDataStreamController.stream, selectedUserName: selectedUserName, - onUserSelectionChanged: (userName, userImagePath) { + onUserSelectionChanged: (userName, userImagePath, userId) { setState(() { selectedUserName = userName; selectedUserImagePath = userImagePath; + this.selectedUserId = userId; }); - try { AnalyticsService.onUserChanged(userName); } catch (e) {} + try { + AnalyticsService.onUserChanged(userName); + } catch (e) {} }, onUserDataSend: (data) { print('🔵 BLE 전송: $data'); ble.sendJson(data); }, ), - AnalyticsScreen(selectedUserName: selectedUserName), - SettingsScreen( connected: connected, sendJson: (data) => ble.sendJson(data), ), - ]; return Scaffold( @@ -270,36 +276,37 @@ class _MainShellState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _buildNavItem( - icon: Icons.dashboard_outlined, - label: '대시보드', - isSelected: _index == 0, - onTap: () => setState(() => _index = 0), - ), + icon: Icons.dashboard_outlined, + label: '대시보드', + isSelected: _index == 0, + onTap: () => setState(() => _index = 0)), _buildNavItem( - icon: Icons.control_camera, - label: '제어', - isSelected: _index == 1, - onTap: () => setState(() => _index = 1), - ), + icon: Icons.control_camera, + label: '제어', + isSelected: _index == 1, + onTap: () => setState(() => _index = 1)), _buildNavItem( - icon: Icons.analytics_outlined, - label: '분석', - isSelected: _index == 2, - onTap: () => setState(() => _index = 2), - ), + icon: Icons.analytics_outlined, + label: '분석', + isSelected: _index == 2, + onTap: () => setState(() => _index = 2)), _buildNavItem( - icon: Icons.settings_outlined, - label: '설정', - isSelected: _index == 3, - onTap: () => setState(() => _index = 3), - ), + icon: Icons.settings_outlined, + label: '설정', + isSelected: _index == 3, + onTap: () => setState(() => _index = 3)), ], ), ), ); } - Widget _buildNavItem({required IconData icon, required String label, required bool isSelected, required VoidCallback onTap}) { + Widget _buildNavItem({ + required IconData icon, + required String label, + required bool isSelected, + required VoidCallback onTap, + }) { return InkWell( onTap: onTap, child: Container( @@ -308,12 +315,23 @@ class _MainShellState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 24, color: isSelected ? const Color(0xFF3A90FF) : const Color(0xFF838799)), + Icon(icon, + size: 24, + color: isSelected + ? const Color(0xFF3A90FF) + : const Color(0xFF838799)), const SizedBox(height: 5), - Text(label, textAlign: TextAlign.center, style: TextStyle(color: isSelected ? const Color(0xFF3A90FF) : const Color(0xFF838799), fontSize: 13, fontFamily: 'Sen', fontWeight: FontWeight.w400)), + Text(label, + textAlign: TextAlign.center, + style: TextStyle( + color: + isSelected ? const Color(0xFF3A90FF) : const Color(0xFF838799), + fontSize: 13, + fontFamily: 'Sen', + fontWeight: FontWeight.w400)), ], ), ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/control_screen.dart b/lib/screens/control_screen.dart index 8ecf13f..ddc6276 100644 --- a/lib/screens/control_screen.dart +++ b/lib/screens/control_screen.dart @@ -15,7 +15,7 @@ class ControlScreen extends StatefulWidget { final String deviceName; final VoidCallback onConnect; final String? selectedUserName; - final Function(String?, String?) onUserSelectionChanged; + final Function(String?, String?, String?) onUserSelectionChanged; final Function(Map)? onUserDataSend; final Stream>? dataStream; @@ -36,54 +36,55 @@ class ControlScreen extends StatefulWidget { class _ControlScreenState extends State { List users = []; - int? selectedUserIndex; // 단일 선택 (하위 호환성) - List selectedUserIndices = []; // 다중 선택 (최대 2명) - - // 스트림 구독 관리 변수 (메모리 누수 방지용) + int? selectedUserIndex; + List selectedUserIndices = []; StreamSubscription? _dataSubscription; + bool isManualControlActive = false; @override void initState() { super.initState(); _loadUsers(); - // 데이터 수신 리스너 등록 - // 화면이 생성될 때 스트림을 구독하고, 데이터가 오면 _handleIncomingData 호출 _dataSubscription = widget.dataStream?.listen((data) { - if (mounted) { - _handleIncomingData(data); - } + if (!mounted) return; + _handleIncomingData(data); }); } @override void dispose() { - // 화면이 종료될 때 구독 취소 _dataSubscription?.cancel(); super.dispose(); } - /// 서버(BLE Gateway)로부터 들어온 데이터 처리 void _handleIncomingData(Map data) { - print("📥 [ControlScreen] 데이터 수신: $data"); + print("[ControlScreen] Data received: $data"); final type = data['type']; if (type == 'REGISTER_ACK') { if (data['success'] == true) { - print("[ControlScreen] ai_service로부터 사용자 등록 성공"); + print("[ControlScreen] User registration success from ai_service"); } else { - print("[ControlScreen] ai_service로부터 사용자 등록 실패: ${data['error']}"); + print("[ControlScreen] User registration failed: ${data['error']}"); } - } - else if (type == 'FACE_DETECTED') { - print("👤 얼굴 감지됨: ${data['user_id']}"); - } - else if (type == 'FACE_LOST') { // 8초동안 얼굴이 보이지 않았을 때 - print("👤 얼굴 인식 실패: ${data['user_id']}"); + } else if (type == 'FACE_DETECTED') { + print("[ControlScreen] Face detected: ${data['user_id']}"); + } else if (type == 'FACE_LOST') { + print("[ControlScreen] Face lost: ${data['user_id']}"); } } - + List> _getSelectedUsersList() { + return selectedUserIndices.map((idx) { + final user = users[idx]; + return { + 'user_id': user.userId, + 'username': user.name, + 'role': selectedUserIndices.indexOf(idx) + 1, + }; + }).toList(); + } Future _loadUsers() async { final prefs = await SharedPreferences.getInstance(); @@ -114,7 +115,6 @@ class _ControlScreenState extends State { await prefs.setStringList('users', usersJson); } - // 2. 사용자 등록 함수 (기존 로직 유지 + ID 전송 확인) Future _addUser() async { final result = await Navigator.push>( context, @@ -122,14 +122,12 @@ class _ControlScreenState extends State { ); if (result != null && result['action'] == 'register') { - // 앱에서 ID 생성 (예: user_1715123456789) - // 이 ID가 시스템 전체에서 쓰이는 최종 ID가 됩니다. final generatedUserId = 'user_${DateTime.now().millisecondsSinceEpoch}'; final newUser = UserProfile( name: result['name']!, imagePath: result['imagePath'], - userId: generatedUserId, // 로컬에 바로 저장 + userId: generatedUserId, ); setState(() { @@ -137,14 +135,13 @@ class _ControlScreenState extends State { }); await _saveUsers(); - // BLE 전송 if (widget.connected && widget.onUserDataSend != null) { final base64Image = await ImageHelper.encodeImageToBase64(result['imagePath']); widget.onUserDataSend!.call({ 'action': 'user_register', - 'name': result['name']!, - 'user_id': generatedUserId, // [중요] 생성한 ID를 Gateway로 보냄 + 'user_id': generatedUserId, + 'username': result['name']!, 'image_base64': base64Image, 'timestamp': DateTime.now().toIso8601String(), }); @@ -177,7 +174,6 @@ class _ControlScreenState extends State { setState(() { users[index] = updatedUser; - // 선택된 사용자라면 정보 갱신을 위해 재전송 if (selectedUserIndices.contains(index)) { _sendUserSelectionToBLE(); } @@ -185,25 +181,23 @@ class _ControlScreenState extends State { await _saveUsers(); if (widget.connected && widget.onUserDataSend != null) { - // 이미지가 변경되었을 수 있으므로 다시 인코딩 (필요시 최적화 가능) final base64Image = await ImageHelper.encodeImageToBase64(result['imagePath']); widget.onUserDataSend!.call({ 'action': 'user_update', 'user_id': updatedUser.userId, 'username': result['name']!, - 'image_base64': base64Image, // 수정 시에도 이미지 전송 (선택사항) + 'image_base64': base64Image, 'timestamp': DateTime.now().toIso8601String(), }); - print('[ControlScreen] 사용자 수정 요청 전송: ${result['name']}'); + print('[ControlScreen] User update request sent: ${result['name']}'); } - } else if (result['action'] == 'delete') { final userToDelete = users[index]; if (widget.connected && widget.onUserDataSend != null) { widget.onUserDataSend!.call({ - 'action': 'user_delete', // Gateway에 맞게 수정 필요할 수 있음 + 'action': 'user_delete', 'user_id': userToDelete.userId, 'timestamp': DateTime.now().toIso8601String(), }); @@ -229,10 +223,10 @@ class _ControlScreenState extends State { if (selectedUserIndices.isNotEmpty) { selectedUserIndex = selectedUserIndices[0]; final firstUser = users[selectedUserIndex!]; - widget.onUserSelectionChanged(firstUser.name, firstUser.imagePath); + widget.onUserSelectionChanged(firstUser.name, firstUser.imagePath, firstUser.userId); } else { selectedUserIndex = null; - widget.onUserSelectionChanged(null, null); + widget.onUserSelectionChanged(null, null, null); } }); @@ -266,9 +260,9 @@ class _ControlScreenState extends State { if (selectedUserIndices.isNotEmpty) { final firstUser = users[selectedUserIndices[0]]; - widget.onUserSelectionChanged(firstUser.name, firstUser.imagePath); + widget.onUserSelectionChanged(firstUser.name, firstUser.imagePath, firstUser.userId); } else { - widget.onUserSelectionChanged(null, null); + widget.onUserSelectionChanged(null, null, null); } }); @@ -277,40 +271,37 @@ class _ControlScreenState extends State { void _sendUserSelectionToBLE() { if (!widget.connected) { - print('[ControlScreen] 연결되지 않아 사용자 선택 전송 불가'); - return; - } - - if (selectedUserIndices.isEmpty) { - if (widget.onUserDataSend != null) { - widget.onUserDataSend!.call({ - 'action': 'user_select', // Gateway 코드와 맞춤 - 'users': [], - 'timestamp': DateTime.now().toIso8601String(), - }); - } + print('[ControlScreen] Cannot send user selection - not connected'); return; } - // 선택된 사용자 리스트 생성 (ID 포함) - List> selectedUsers = selectedUserIndices.map((idx) { - final user = users[idx]; - return { - 'user_id': user.userId, // 서버가 준 ID 사용 - 'name': user.name, - 'role': selectedUserIndices.indexOf(idx) + 1, - }; - }).toList(); + List> selectedUsers = _getSelectedUsersList(); if (widget.onUserDataSend != null) { widget.onUserDataSend!.call({ 'action': 'user_select', - 'users': selectedUsers, + 'user_list': selectedUsers, 'timestamp': DateTime.now().toIso8601String(), }); + + if (selectedUsers.isEmpty) { + widget.onUserDataSend!.call({ + 'action': 'mode_change', + 'mode': 'manual', + 'timestamp': DateTime.now().toIso8601String(), + }); + isManualControlActive = false; + } else if (!isManualControlActive) { + widget.onUserDataSend!.call({ + 'action': 'mode_change', + 'mode': 'ai', + 'timestamp': DateTime.now().toIso8601String(), + }); + } } - print('[ControlScreen] 👥 선택된 사용자 전송: ${selectedUsers.length}명'); + print('[ControlScreen] Selected users sent: ${selectedUsers.length} users'); + print('[ControlScreen] User list: $selectedUsers'); } void _reorderSelectedUsers() { @@ -321,40 +312,70 @@ class _ControlScreenState extends State { setState(() { selectedUserIndices.clear(); selectedUserIndex = null; - widget.onUserSelectionChanged(null, null); + widget.onUserSelectionChanged(null, null, null); }); if (widget.connected && widget.onUserDataSend != null) { widget.onUserDataSend!.call({ 'action': 'user_select', - 'users': [], + 'user_list': [], 'timestamp': DateTime.now().toIso8601String(), }); } } - void _sendCommand(String direction) { + String _lowercaseDirection(String direction) { + if (direction.isEmpty) return direction; + return direction[0].toLowerCase(); + } + + void _sendCommand(String direction, int toggleOn) { if (!widget.connected) { - print('[ControlScreen] 연결되지 않아 명령 전송 불가'); + print('[ControlScreen] Cannot send command - not connected'); return; } - // 수동 제어는 별도 액션으로 처리 - String action = 'manual_control'; // 또는 angle_change 등 Gateway 구현에 맞춤 + final formattedDirection = _lowercaseDirection(direction); + final selectedUsers = _getSelectedUsersList(); if (widget.onUserDataSend != null) { + if (toggleOn == 1) { + isManualControlActive = true; + + widget.onUserDataSend!.call({ + 'action': 'mode_change', + 'mode': 'manual', + 'user_list': selectedUsers, + 'timestamp': DateTime.now().toIso8601String(), + }); + } else if (toggleOn == 0) { + isManualControlActive = false; + + if (selectedUserIndices.isNotEmpty) { + widget.onUserDataSend!.call({ + 'action': 'mode_change', + 'mode': 'ai', + 'user_list': selectedUsers, + 'timestamp': DateTime.now().toIso8601String(), + }); + } + } + widget.onUserDataSend!.call({ 'action': 'angle_change', - 'angle': direction, + 'direction': formattedDirection, + 'toggleOn': toggleOn, + 'user_list': selectedUsers, 'timestamp': DateTime.now().toIso8601String(), }); } - // Analytics + print('[ControlScreen] Command sent: $formattedDirection (toggleOn: $toggleOn)'); + try { - AnalyticsService.onManualControl(direction, null); + AnalyticsService.onManualControl(formattedDirection, null); } catch (e) { - print('[ControlScreen] AnalyticsService 오류: $e'); + print('[ControlScreen] AnalyticsService error: $e'); } } @@ -379,8 +400,6 @@ class _ControlScreenState extends State { : null, ), const SizedBox(height: 16), - - // 상단 버튼 영역 (전체 해제, 편집) Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Row( @@ -392,23 +411,26 @@ class _ControlScreenState extends State { icon: const Icon(Icons.clear_all, size: 16), label: const Text('전체 해제'), style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), foregroundColor: Colors.orange, backgroundColor: Colors.orange.withOpacity(0.1), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), ), ) else const SizedBox.shrink(), - TextButton.icon( - onPressed: selectedUserIndices.isNotEmpty && selectedUserIndices.length == 1 + onPressed: selectedUserIndices.isNotEmpty && + selectedUserIndices.length == 1 ? () => _editUser(selectedUserIndices[0]) : null, icon: Icon( Icons.edit_outlined, size: 18, - color: selectedUserIndices.isNotEmpty && selectedUserIndices.length == 1 + color: selectedUserIndices.isNotEmpty && + selectedUserIndices.length == 1 ? const Color(0xFF3A90FF) : Colors.grey, ), @@ -418,25 +440,27 @@ class _ControlScreenState extends State { fontFamily: 'Sen', fontSize: 14, fontWeight: FontWeight.w600, - color: selectedUserIndices.isNotEmpty && selectedUserIndices.length == 1 + color: selectedUserIndices.isNotEmpty && + selectedUserIndices.length == 1 ? const Color(0xFF3A90FF) : Colors.grey, ), ), style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - backgroundColor: selectedUserIndices.isNotEmpty && selectedUserIndices.length == 1 + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + backgroundColor: selectedUserIndices.isNotEmpty && + selectedUserIndices.length == 1 ? const Color(0xFF3A90FF).withOpacity(0.1) : Colors.grey.withOpacity(0.1), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), ), ), ], ), ), const SizedBox(height: 12), - - // 사용자 리스트 뷰 SizedBox( height: 110, child: ListView.builder( @@ -449,9 +473,8 @@ class _ControlScreenState extends State { } final userIndex = index - 1; final isSelected = selectedUserIndices.contains(userIndex); - final selectionOrder = isSelected - ? selectedUserIndices.indexOf(userIndex) + 1 - : null; + final selectionOrder = + isSelected ? selectedUserIndices.indexOf(userIndex) + 1 : null; return _UserCard( user: users[userIndex], isSelected: isSelected, @@ -462,17 +485,21 @@ class _ControlScreenState extends State { ), ), const SizedBox(height: 40), - - // D-Pad 컨트롤러 + // D-Pad controller Expanded( child: Center( child: RemoteControlDpad( size: 280, - onUp: () => _sendCommand('up'), - onDown: () => _sendCommand('down'), - onLeft: () => _sendCommand('left'), - onRight: () => _sendCommand('right'), - onCenter: () => _sendCommand('center'), + onUp: () => _sendCommand('up', 1), + onUpEnd: () => _sendCommand('up', 0), + onDown: () => _sendCommand('down', 1), + onDownEnd: () => _sendCommand('down', 0), + onLeft: () => _sendCommand('left', 1), + onLeftEnd: () => _sendCommand('left', 0), + onRight: () => _sendCommand('right', 1), + onRightEnd: () => _sendCommand('right', 0), + onCenter: () => _sendCommand('center', 1), + onCenterEnd: () => _sendCommand('center', 0), ), ), ), @@ -483,10 +510,6 @@ class _ControlScreenState extends State { } } -// ========================================== -// Helper Classes & Widgets -// ========================================== - class UserProfile { final String name; final String? avatarUrl; @@ -602,7 +625,8 @@ class _UserCard extends StatelessWidget { border: isSelected ? Border.all(color: borderColor, width: 3) : null, boxShadow: [ BoxShadow( - color: isSelected ? borderColor.withOpacity(0.2) : Colors.black.withOpacity(0.05), + color: + isSelected ? borderColor.withOpacity(0.2) : Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 2), ), @@ -686,4 +710,4 @@ class _UserCard extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/device_selection_screen.dart b/lib/screens/device_selection_screen.dart index c408b1a..39e31c6 100644 --- a/lib/screens/device_selection_screen.dart +++ b/lib/screens/device_selection_screen.dart @@ -1,7 +1,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:ambient_node/services/ble_service.dart'; // BleService, BleConnectionState 포함 +import 'package:ambient_node/services/ble_service.dart'; + +class UIColors { + static const kColorCyan = Color(0xFF00BCD4); + static const kColorSlate200 = Color(0xFFE2E8F0); + static const kColorSlate500 = Color(0xFF64748B); + static const kColorBgLight = Color(0xFFF8FAFC); +} class DeviceSelectionScreen extends StatefulWidget { final BleService bleService; @@ -19,51 +26,75 @@ class DeviceSelectionScreen extends StatefulWidget { State createState() => _DeviceSelectionScreenState(); } -class _DeviceSelectionScreenState extends State { +class _DeviceSelectionScreenState extends State with SingleTickerProviderStateMixin { List _devices = []; - // 디바이스 ID별 연결 상태 메시지 저장 final Map _connectionStates = {}; bool _isScanning = false; - bool _hasConnectedDevice = false; + // _hasConnectedDevice는 UI 상태용이며, 실제 연결 상태는 bleService를 신뢰합니다. - // 스트림 구독 관리 StreamSubscription? _connectionStateSubscription; StreamSubscription? _scanSubscription; + late AnimationController _radarController; + @override void initState() { super.initState(); + _radarController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..repeat(); + _setupListeners(); _startScanning(); } @override void dispose() { - _stopScanning(); + // 1. 애니메이션/구독 먼저 해제 + _radarController.dispose(); + _scanSubscription?.cancel(); _connectionStateSubscription?.cancel(); + + // 2. 스캔 중지 + try { + widget.bleService.stopScan(); + } catch (e) { + print('Dispose stopScan error: $e'); + } super.dispose(); } void _setupListeners() { - // 1. 연결 상태 스트림 구독 _connectionStateSubscription = widget.bleService.connectionStateStream.listen((state) { - print('[DeviceSelection] 연결 상태 변경: $state'); - + // [중요] 비동기 콜백에서 UI 갱신 전 반드시 mounted 체크 if (!mounted) return; setState(() { - _hasConnectedDevice = (state == BleConnectionState.connected); - - // 상위 위젯에 알림 - widget.onConnectionChanged(_hasConnectedDevice); + final isConnected = (state == BleConnectionState.connected); + widget.onConnectionChanged(isConnected); if (state == BleConnectionState.connected) { + // 이미 닫힌 화면이거나 이동 중이면 무시 + if (!mounted) return; + + ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('디바이스가 연결되었습니다'), backgroundColor: Colors.green), ); - Navigator.of(context).pop(); // 연결 성공 시 화면 닫기 (선택 사항) - } else if (state == BleConnectionState.error) { + + // 연결 성공 시 화면 닫기 (pop) + if (Navigator.canPop(context)) { + Navigator.of(context).pop(); + } + } + else if (state == BleConnectionState.disconnected) { + // 연결 끊김 상태 업데이트 + // (Status 22로 끊길 때 여기서 UI 갱신 시도하다가 죽는 것 방지) + } + else if (state == BleConnectionState.error) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('연결 중 오류가 발생했습니다'), backgroundColor: Colors.red), ); @@ -74,22 +105,20 @@ class _DeviceSelectionScreenState extends State { void _startScanning() { if (_isScanning) return; + if (!mounted) return; setState(() => _isScanning = true); + if (!_radarController.isAnimating) _radarController.repeat(); - // 2. 스캔 스트림 구독 _scanSubscription = widget.bleService.startScan().listen( (scanResults) { if (mounted) { setState(() { - // ScanResult에서 BluetoothDevice 추출 및 중복 제거 _devices = scanResults.map((r) => r.device).toList(); - print('[DeviceSelection] 발견된 기기: ${_devices.length}개'); }); } }, onError: (e) { - print('[DeviceSelection] 스캔 오류: $e'); if (mounted) setState(() => _isScanning = false); }, onDone: () { @@ -101,50 +130,57 @@ class _DeviceSelectionScreenState extends State { void _stopScanning() { widget.bleService.stopScan(); _scanSubscription?.cancel(); - if (mounted) setState(() => _isScanning = false); + if (mounted) { + setState(() => _isScanning = false); + _radarController.stop(); + } } Future _connectToDevice(BluetoothDevice device) async { final deviceId = device.remoteId.toString(); - // 스캔 중지 (연결 시도 전) + // 연결 시도 전 스캔 확실히 중지 _stopScanning(); - setState(() { - _connectionStates[deviceId] = '연결 중...'; - }); + if (!mounted) return; + setState(() => _connectionStates[deviceId] = '연결 및 페어링 중...'); try { - // 새로운 BleService.connect는 Future이며 실패 시 throw함 - await widget.bleService.connect(device); + // [수정 포인트] BleService.connect 안에서 딜레이를 주는 것이 가장 좋으나, + // 서비스 코드를 수정할 수 없다면 여기서라도 딜레이를 줄 수는 없습니다. + // (connect 함수가 끝날 때는 이미 연결이 완료된 후이기 때문) + // 따라서 여기서는 에러 핸들링만 강화합니다. - // 성공 처리는 _setupListeners의 스트림에서 처리됨 - setState(() { - _connectionStates[deviceId] = '연결 완료'; - if (widget.onDeviceNameChanged != null) { - widget.onDeviceNameChanged!(device.platformName); - } - }); + await widget.bleService.connect(device); - } catch (e) { - print('[DeviceSelection] 연결 예외: $e'); + // connect가 에러 없이 반환되면 연결 성공으로 간주 if (mounted) { setState(() { - _connectionStates[deviceId] = '연결 실패'; - }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('연결 실패: $e'), backgroundColor: Colors.red), - ); - // 3초 후 상태 메시지 초기화 - Future.delayed(const Duration(seconds: 3), () { - if (mounted) setState(() => _connectionStates.remove(deviceId)); + _connectionStates[deviceId] = '연결 완료'; + if (widget.onDeviceNameChanged != null) { + widget.onDeviceNameChanged!(device.platformName); + } }); } + } catch (e) { + if (!mounted) return; + + setState(() => _connectionStates[deviceId] = '연결 실패'); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('연결 실패: $e'), backgroundColor: Colors.red), + ); + + Future.delayed(const Duration(seconds: 3), () { + if (mounted) setState(() => _connectionStates.remove(deviceId)); + }); } } Future _disconnectFromDevice(BluetoothDevice device) async { final deviceId = device.remoteId.toString(); + if (!mounted) return; + setState(() => _connectionStates[deviceId] = '해제 중...'); try { @@ -152,93 +188,196 @@ class _DeviceSelectionScreenState extends State { if (mounted) { setState(() { _connectionStates.remove(deviceId); - _hasConnectedDevice = false; }); } } catch (e) { - print('[DeviceSelection] 해제 실패: $e'); + print('해제 실패: $e'); + if (mounted) { + setState(() => _connectionStates.remove(deviceId)); // 실패해도 UI는 초기화 + } } } @override Widget build(BuildContext context) { + // ... UI 코드는 기존과 동일 ... + // (위쪽 코드와 똑같이 유지하면 됩니다) return Scaffold( - appBar: AppBar( - title: const Text('블루투스 기기 선택'), - backgroundColor: Colors.blue[600], - foregroundColor: Colors.white, + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.black87), + onPressed: () => Navigator.pop(context), + ), + const Expanded( + child: Text( + '기기 연결', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ), + const SizedBox(width: 48), + ], + ), + ), + const SizedBox(height: 20), + Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 200, + height: 200, + child: AnimatedBuilder( + animation: _radarController, + builder: (context, child) { + return Stack( + children: [0, 1, 2].map((i) { + double radius = 100 * ((_radarController.value + i * 0.33) % 1.0); + double opacity = 1.0 - ((_radarController.value + i * 0.33) % 1.0); + if (!_isScanning) opacity = 0.1; + + return Center( + child: Container( + width: radius * 2, + height: radius * 2, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: UIColors.kColorCyan.withOpacity(opacity), + width: 1.5 + ), + ), + ), + ); + }).toList(), + ); + }, + ), + ), + Icon( + Icons.bluetooth, + size: 40, + color: _isScanning ? const Color(0xFF00BCD4) : Colors.grey, + ), + ], + ), + const SizedBox(height: 20), + Text( + _isScanning ? "주변 기기 검색 중..." : "검색 완료", + style: const TextStyle(color: Colors.grey, fontSize: 14), + ), + if (!_isScanning) + TextButton( + onPressed: _startScanning, + child: const Text("다시 스캔", style: TextStyle(color: UIColors.kColorCyan)), + ), + const SizedBox(height: 20), + Expanded( + child: _devices.isEmpty + ? Center( + child: Text( + _isScanning ? '' : '발견된 기기가 없습니다.', + style: TextStyle(color: Colors.grey[400]), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), + itemCount: _devices.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _buildDeviceTile(_devices[index]), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildDeviceTile(BluetoothDevice device) { + final deviceId = device.remoteId.toString(); + final status = _connectionStates[deviceId] ?? ''; + + // 연결 상태 판단 로직 강화 + final isConnected = widget.bleService.currentState == BleConnectionState.connected && + (status == '연결 완료' || status.isEmpty); + + final deviceName = device.platformName.isNotEmpty ? device.platformName : '알 수 없는 기기'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UIColors.kColorSlate200.withOpacity(0.5), + blurRadius: 10, + offset: const Offset(0, 4), + ) + ], + border: Border.all(color: isConnected ? UIColors.kColorCyan : UIColors.kColorSlate200), ), - body: Column( + child: Row( children: [ - // 상단 상태 바 Container( - padding: const EdgeInsets.all(16), - color: _isScanning ? Colors.blue[50] : Colors.grey[100], - child: Row( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: isConnected ? const Color(0xFFE0F7FA) : Colors.grey[100], + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.air, + color: isConnected ? const Color(0xFF00BCD4) : Colors.grey, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _isScanning - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : Icon(Icons.bluetooth, color: Colors.grey[600]), - const SizedBox(width: 12), - Text(_isScanning ? '기기 스캔 중...' : '스캔 완료'), - const Spacer(), - ElevatedButton( - onPressed: _isScanning ? _stopScanning : _startScanning, - child: Text(_isScanning ? '중지' : '다시 스캔'), + Text( + deviceName, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + Text( + deviceId, + style: TextStyle(fontSize: 12, color: Colors.grey[500]), ), ], ), ), - // 기기 리스트 - Expanded( - child: _devices.isEmpty - ? Center( - child: Text( - _isScanning ? '기기를 찾는 중...' : '발견된 기기가 없습니다.', - style: TextStyle(color: Colors.grey[600]), - ), - ) - : ListView.builder( - itemCount: _devices.length, - itemBuilder: (context, index) { - final device = _devices[index]; - final deviceId = device.remoteId.toString(); - final status = _connectionStates[deviceId] ?? ''; - final isConnected = widget.bleService.currentState == BleConnectionState.connected && - status == '연결 완료'; - - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: ListTile( - leading: Icon( - Icons.bluetooth, - color: isConnected ? Colors.blue : Colors.grey, - ), - title: Text(device.platformName.isNotEmpty ? device.platformName : '알 수 없는 기기'), - subtitle: Text(deviceId), - trailing: status.isNotEmpty - ? Text(status, style: TextStyle( - color: status.contains('실패') ? Colors.red : Colors.blue, - fontWeight: FontWeight.bold)) - : null, - onTap: isConnected - ? () => _disconnectFromDevice(device) - : () => _connectToDevice(device), - ), - ); - }, + ElevatedButton( + onPressed: isConnected + ? () => _disconnectFromDevice(device) + : () => _connectToDevice(device), + style: ElevatedButton.styleFrom( + backgroundColor: isConnected ? Colors.grey[300] : const Color(0xFF00BCD4), + foregroundColor: isConnected ? Colors.black87 : Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + ), + child: Text( + status.isNotEmpty ? status : (isConnected ? "해제" : "연결"), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), ), ), ], ), - bottomNavigationBar: Padding( - padding: const EdgeInsets.all(16), - child: ElevatedButton( - onPressed: () => Navigator.of(context).pop(), - style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), - child: const Text('닫기'), - ), - ), ); } } \ No newline at end of file diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c0cd8ee..9fa7b2d 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -13,8 +13,12 @@ class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: const Color(0xFFF6F7F8), // 배경색 살짝 회색으로 (선택사항) appBar: AppBar( title: const Text("설정"), + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 0, ), body: ListView( padding: const EdgeInsets.all(20), @@ -23,13 +27,46 @@ class SettingsScreen extends StatelessWidget { "시스템 관리", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), - const SizedBox(height: 10), + const SizedBox(height: 16), + // 재부팅 카드 Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: ListTile( - leading: const Icon(Icons.power_settings_new, color: Colors.red), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon(Icons.restart_alt, color: Colors.orange), + ), + title: const Text("기기 재부팅"), + subtitle: const Text("라즈베리파이를 재시작합니다."), + onTap: () => _showRebootDialog(context), + ), + ), + + const SizedBox(height: 12), + + // 종료 카드 + Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon(Icons.power_settings_new, color: Colors.red), + ), title: const Text("기기 강제 종료"), - subtitle: const Text("라즈베리파이를 안전하게 종료합니다."), + subtitle: const Text("라즈베리파이 전원을 끕니다."), onTap: () => _showShutdownDialog(context), ), ), @@ -38,44 +75,73 @@ class SettingsScreen extends StatelessWidget { ); } - void _showShutdownDialog(BuildContext context) { + // 재부팅 다이얼로그 + void _showRebootDialog(BuildContext context) { showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text("기기 강제 종료"), - content: const Text("라즈베리파이를 종료하시겠습니까?"), + title: const Text("시스템 재부팅"), + content: const Text("라즈베리파이를 재시작하시겠습니까?\n연결이 일시적으로 끊어집니다."), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text("취소"), + child: const Text("취소", style: TextStyle(color: Colors.grey)), ), TextButton( onPressed: () { Navigator.pop(ctx); + _sendCommand(context, "reboot", "재부팅 명령을 전송했습니다."); + }, + child: const Text("재시작", style: TextStyle(color: Colors.orange)), + ), + ], + ), + ); + } - if (!connected) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("BLE 연결이 필요합니다."), - ), - ); - return; - } - - // BLE로 shutdown 명령 전송 - sendJson({ - "action": "shutdown", - "timestamp": DateTime.now().toIso8601String(), - }); - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("기기 종료 명령을 전송했습니다.")), - ); + // 종료 다이얼로그 + void _showShutdownDialog(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("시스템 종료"), + content: const Text("라즈베리파이를 완전히 종료하시겠습니까?\n다시 켜려면 전원을 재연결해야 합니다."), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text("취소", style: TextStyle(color: Colors.grey)), + ), + TextButton( + onPressed: () { + Navigator.pop(ctx); + _sendCommand(context, "shutdown", "종료 명령을 전송했습니다."); }, - child: const Text("종료"), + child: const Text("종료", style: TextStyle(color: Colors.red)), ), ], ), ); } -} + + // 명령 전송 공통 함수 + void _sendCommand(BuildContext context, String action, String message) { + if (!connected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("BLE 연결이 필요합니다."), + backgroundColor: Colors.red, + ), + ); + return; + } + + sendJson({ + "action": action, // 'reboot' 또는 'shutdown' + "timestamp": DateTime.now().toIso8601String(), + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } +} \ No newline at end of file diff --git a/lib/services/ble_service.dart b/lib/services/ble_service.dart index 8b8c711..cfd68b6 100644 --- a/lib/services/ble_service.dart +++ b/lib/services/ble_service.dart @@ -114,29 +114,44 @@ class BleService { /// 디바이스 연결 Future connect(BluetoothDevice device) async { - // 이미 연결 중이거나 연결된 상태면 무시 + // 1. 이미 연결 중이거나 연결된 상태면 무시 if (_connectionStateController.value == BleConnectionState.connecting || _connectionStateController.value == BleConnectionState.connected) { return; } + // 2. UI 상태를 먼저 '연결 중'으로 변경 _updateState(BleConnectionState.connecting); - stopScan(); + stopScan(); // 연결 시도 전 스캔 중지 (권장사항) try { _log('${device.platformName}에 연결 시도 중...'); + // 3. 물리적 연결 시도 (여기서 딱 한 번만 호출해야 합니다) await device.connect( timeout: const Duration(seconds: 15), - autoConnect: false, // 안정성을 위해 false 권장 + autoConnect: false, // false가 페어링 프로세스에서 더 안정적입니다. ); + // --------------------------------------------------------- + // [핵심] 안드로이드 Status 22 및 페어링 튕김 방지 딜레이 + // 연결 직후 바로 데이터를 주고받으려 하면 안드로이드가 연결을 끊어버립니다. + // --------------------------------------------------------- + if (Platform.isAndroid) { + await Future.delayed(const Duration(seconds: 2)); + } + + // 4. 서비스 탐색 (딜레이 이후에 실행해야 안전함) + // (flutter_blue_plus의 내장 함수 호출) + await device.discoverServices(); + _connectedDevice = device; - // 서비스 및 Characteristic 탐색 + // 5. Notification 구독 등 커스텀 로직 실행 + // (기존 코드에 있던 _discoverServices 함수가 Notification 설정을 담당한다고 가정합니다) await _discoverServices(device); - // 연결 끊김 모니터링 + // 6. 연결 끊김 모니터링 리스너 등록 _deviceConnectionSubscription?.cancel(); _deviceConnectionSubscription = device.connectionState.listen((state) { if (state == BluetoothConnectionState.disconnected) { @@ -144,17 +159,25 @@ class BleService { } }); + // 7. 최종 연결 성공 상태 업데이트 _updateState(BleConnectionState.connected); _log('연결 성공.'); } catch (e) { _log('연결 실패: $e'); + + // 실패 시 확실하게 연결 해제 시도 + try { + await device.disconnect(); + } catch (e) {} + _updateState(BleConnectionState.error); + // 에러 메시지를 보여줄 시간을 주고 상태 초기화 Future.delayed(const Duration(seconds: 2), () { _updateState(BleConnectionState.disconnected); }); - rethrow; + // rethrow; // 필요하다면 주석 해제 (UI에서 에러를 따로 잡아서 처리하고 싶을 때) } } @@ -268,10 +291,13 @@ class BleService { // 일반 JSON 데이터 처리 final jsonMap = json.decode(str); - _dataStreamController.add(jsonMap); + if (_dataStreamController.hasListener) { + _dataStreamController.add(jsonMap); + } _log('수신: $str'); } catch (e) { _log('데이터 파싱 오류: $e'); + return; } } diff --git a/lib/widgets/remote_control_dpad.dart b/lib/widgets/remote_control_dpad.dart index 69d7ff3..844d851 100644 --- a/lib/widgets/remote_control_dpad.dart +++ b/lib/widgets/remote_control_dpad.dart @@ -1,190 +1,197 @@ +// lib/widgets/remote_control_dpad.dart import 'package:flutter/material.dart'; -import 'dart:math' as math; -/// 뉴모피즘 스타일의 원형 방향키 위젯입니다. -/// 각 방향과 중앙 버튼에 대한 콜백 함수를 전달할 수 있습니다. +// UI 색상 정의 +class RemoteColors { + static const kColorSlate200 = Color(0xFFE2E8F0); + static const kColorSlate500 = Color(0xFF64748B); + static const kColorBgLight = Color(0xFFF8FAFC); + static const kColorCyan = Color(0xFF00BCD4); + static const kColorBlue = Color(0xFF3B82F6); +} + class RemoteControlDpad extends StatelessWidget { - final VoidCallback? onUp; - final VoidCallback? onDown; - final VoidCallback? onLeft; - final VoidCallback? onRight; - final VoidCallback? onCenter; final double size; + final VoidCallback onUp; + final VoidCallback onUpEnd; + final VoidCallback onDown; + final VoidCallback onDownEnd; + final VoidCallback onLeft; + final VoidCallback onLeftEnd; + final VoidCallback onRight; + final VoidCallback onRightEnd; + final VoidCallback onCenter; + final VoidCallback onCenterEnd; const RemoteControlDpad({ super.key, - this.onUp, - this.onDown, - this.onLeft, - this.onRight, - this.onCenter, - this.size = 250.0, + required this.size, + required this.onUp, + required this.onUpEnd, + required this.onDown, + required this.onDownEnd, + required this.onLeft, + required this.onLeftEnd, + required this.onRight, + required this.onRightEnd, + required this.onCenter, + required this.onCenterEnd, }); @override Widget build(BuildContext context) { - // 뉴모피즘 스타일에 사용할 색상들 - const primaryColor = Color(0xFFE0E5EC); // 기본 컨테이너 색 - const darkShadowColor = Color(0xFFA3B1C6); // 어두운 그림자 - const lightShadowColor = Colors.white; // 밝은 그림자 - final iconColor = Colors.grey.shade600; // 아이콘 색상 + // UI 비율 계산 + final double innerCircleSize = size * 0.6; // 180/300 비율 + final double dpadButtonSize = 60.0; + final double centerButtonSize = 80.0; - return Center( - child: SizedBox( - width: size, - height: size, - child: Stack( - alignment: Alignment.center, - children: [ - // 1. 가장 바깥쪽 베이스 (살짝 들어간 효과) - Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: primaryColor, - boxShadow: [ - BoxShadow( - color: darkShadowColor.withOpacity(0.5), - offset: const Offset(5, 5), - blurRadius: 15, - spreadRadius: 1, - ), - const BoxShadow( - color: lightShadowColor, - offset: Offset(-5, -5), - blurRadius: 15, - spreadRadius: 1, - ), - ], + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: RemoteColors.kColorSlate200.withOpacity(0.6), + blurRadius: 30, + offset: const Offset(0, 10), + ), + const BoxShadow( + color: Colors.white, + blurRadius: 20, + spreadRadius: -5, + ) + ], + ), + child: Stack( + alignment: Alignment.center, + children: [ + // 외곽 데코레이션 라인 + Container( + width: size - 20, + height: size - 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: RemoteColors.kColorSlate200.withOpacity(0.3), ), ), - // 2. 안쪽 링 (방향키가 위치할 영역) - Container( - width: size * 0.9, - height: size * 0.9, - decoration: const BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - lightShadowColor, - primaryColor, - ], - ), - ), + ), + // 내부 배경 원 + Container( + width: innerCircleSize, + height: innerCircleSize, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: RemoteColors.kColorBgLight, + ), + ), + + // 상단 버튼 + Positioned( + top: 20, + child: _buildGestureButton( + onTapDown: onUp, + onTapUp: onUpEnd, + icon: Icons.keyboard_arrow_up, + size: dpadButtonSize, + ), + ), + // 하단 버튼 + Positioned( + bottom: 20, + child: _buildGestureButton( + onTapDown: onDown, + onTapUp: onDownEnd, + icon: Icons.keyboard_arrow_down, + size: dpadButtonSize, + ), + ), + // 좌측 버튼 + Positioned( + left: 20, + child: _buildGestureButton( + onTapDown: onLeft, + onTapUp: onLeftEnd, + icon: Icons.keyboard_arrow_left, + size: dpadButtonSize, + ), + ), + // 우측 버튼 + Positioned( + right: 20, + child: _buildGestureButton( + onTapDown: onRight, + onTapUp: onRightEnd, + icon: Icons.keyboard_arrow_right, + size: dpadButtonSize, ), - // 3. 중앙 버튼 베이스 (가장 튀어나온 부분) - Container( - width: size * 0.6, - height: size * 0.6, + ), + + // 중앙 버튼 (설정값 복원/센터링) + GestureDetector( + onTapDown: (_) => onCenter(), + onTapUp: (_) => onCenterEnd(), + onTapCancel: () => onCenterEnd(), + child: Container( + width: centerButtonSize, + height: centerButtonSize, decoration: BoxDecoration( shape: BoxShape.circle, - color: primaryColor, + gradient: const LinearGradient( + colors: [RemoteColors.kColorCyan, RemoteColors.kColorBlue], + ), boxShadow: [ BoxShadow( - color: darkShadowColor.withOpacity(0.7), - offset: const Offset(4, 4), - blurRadius: 10, - ), - const BoxShadow( - color: lightShadowColor, - offset: Offset(-4, -4), + color: RemoteColors.kColorCyan.withOpacity(0.4), blurRadius: 10, + offset: const Offset(0, 4), ), ], ), - ), - // 4. 중앙 버튼 터치 영역 - GestureDetector( - onTap: onCenter, - child: Container( - width: size * 0.55, - height: size * 0.55, - decoration: const BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - primaryColor, - lightShadowColor, - ], - stops: [0.8, 1.0], - ), - ), + child: const Icon( + Icons.settings_backup_restore, + color: Colors.white, + size: 32, ), ), - - // 5. 방향키 버튼들 - _DirectionalButton( - alignment: Alignment.topCenter, - icon: Icons.play_arrow, - rotation: -math.pi / 2, - onTap: onUp, - iconColor: iconColor), - _DirectionalButton( - alignment: Alignment.bottomCenter, - icon: Icons.play_arrow, - rotation: math.pi / 2, - onTap: onDown, - iconColor: iconColor), - _DirectionalButton( - alignment: Alignment.centerLeft, - icon: Icons.play_arrow, - rotation: math.pi, - onTap: onLeft, - iconColor: iconColor), - _DirectionalButton( - alignment: Alignment.centerRight, - icon: Icons.play_arrow, - rotation: 0, - onTap: onRight, - iconColor: iconColor), - ], - ), + ), + ], ), ); } -} -/// 방향키 버튼을 위한 내부 헬퍼 위젯 -class _DirectionalButton extends StatelessWidget { - final Alignment alignment; - final IconData icon; - final double rotation; - final VoidCallback? onTap; - final Color iconColor; - - const _DirectionalButton({ - required this.alignment, - required this.icon, - required this.rotation, - this.onTap, - required this.iconColor, - }); - - @override - Widget build(BuildContext context) { - return Align( - alignment: alignment, - child: SizedBox( - width: 60, - height: 60, - child: Material( - color: Colors.transparent, - child: InkWell( - splashColor: Colors.grey.withOpacity(0.2), - borderRadius: BorderRadius.circular(30), - onTap: onTap, - child: Center( - child: Transform.rotate( - angle: rotation, - child: Icon(icon, color: iconColor, size: 30), - ), + Widget _buildGestureButton({ + required VoidCallback onTapDown, + required VoidCallback onTapUp, + required IconData icon, + required double size, + }) { + return GestureDetector( + onTapDown: (_) => onTapDown(), + onTapUp: (_) => onTapUp(), + onTapCancel: () => onTapUp(), // 취소 시에도 명령 종료 처리 + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: RemoteColors.kColorSlate200.withOpacity(0.8), + blurRadius: 8, + offset: const Offset(0, 4), ), - ), + ], + ), + child: Icon( + icon, + color: RemoteColors.kColorSlate500, + size: 28, ), ), ); } -} +} \ No newline at end of file