From bc57468aaa6edb888674f1189e71aa970816d68c Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Tue, 25 Nov 2025 16:34:13 +0900 Subject: [PATCH] ... --- .dart_tool/extension_discovery/README.md | 31 ++ .dart_tool/extension_discovery/vs_code.json | 1 + apps/app/android/app/build.gradle.kts | 7 + .../android/app/src/main/AndroidManifest.xml | 11 + apps/app/ios/Podfile.lock | 37 +- apps/app/ios/Runner/Info.plist | 14 +- apps/app/lib/intro/intro_screen.dart | 44 ++ apps/app/lib/intro/intro_view.dart | 194 ++++++++ apps/app/lib/intro_screen.dart | 94 ---- apps/app/lib/login_screen.dart | 167 +++++++ apps/app/lib/main.dart | 76 +-- apps/app/lib/screens/settings_screen.dart | 187 +++++++ .../Flutter/GeneratedPluginRegistrant.swift | 10 + apps/app/pubspec.lock | 140 +++++- .../flutter/generated_plugin_registrant.cc | 6 + .../windows/flutter/generated_plugins.cmake | 2 + .../core/lib/manager/global_chat_manager.dart | 49 +- packages/core/lib/manager/media_manager.dart | 254 +++++++--- .../lib/manager/notification_manager.dart | 70 +++ .../core/lib/manager/settings_manager.dart | 149 ++++++ packages/core/lib/manager/voice_manager.dart | 100 ++++ packages/core/lib/model/user_info.dart | 18 +- .../core/lib/network/network_manager.dart | 240 +++++---- packages/core/lib/playwith_core.dart | 14 +- packages/core/lib/widgets/avatar_widget.dart | 73 +++ .../core/lib/widgets/game_chat_overlay.dart | 313 +++++++++--- packages/core/lib/widgets/voice_widget.dart | 71 +++ packages/core/pubspec.yaml | 10 +- packages/games/quiz/lib/quiz_game.dart | 465 ++++++++---------- 29 files changed, 2206 insertions(+), 641 deletions(-) create mode 100644 .dart_tool/extension_discovery/README.md create mode 100644 .dart_tool/extension_discovery/vs_code.json create mode 100644 apps/app/lib/intro/intro_screen.dart create mode 100644 apps/app/lib/intro/intro_view.dart delete mode 100644 apps/app/lib/intro_screen.dart create mode 100644 apps/app/lib/login_screen.dart create mode 100644 apps/app/lib/screens/settings_screen.dart create mode 100644 packages/core/lib/manager/notification_manager.dart create mode 100644 packages/core/lib/manager/settings_manager.dart create mode 100644 packages/core/lib/manager/voice_manager.dart create mode 100644 packages/core/lib/widgets/avatar_widget.dart create mode 100644 packages/core/lib/widgets/voice_widget.dart diff --git a/.dart_tool/extension_discovery/README.md b/.dart_tool/extension_discovery/README.md new file mode 100644 index 0000000..9dc6757 --- /dev/null +++ b/.dart_tool/extension_discovery/README.md @@ -0,0 +1,31 @@ +Extension Discovery Cache +========================= + +This folder is used by `package:extension_discovery` to cache lists of +packages that contains extensions for other packages. + +DO NOT USE THIS FOLDER +---------------------- + + * Do not read (or rely) the contents of this folder. + * Do write to this folder. + +If you're interested in the lists of extensions stored in this folder use the +API offered by package `extension_discovery` to get this information. + +If this package doesn't work for your use-case, then don't try to read the +contents of this folder. It may change, and will not remain stable. + +Use package `extension_discovery` +--------------------------------- + +If you want to access information from this folder. + +Feel free to delete this folder +------------------------------- + +Files in this folder act as a cache, and the cache is discarded if the files +are older than the modification time of `.dart_tool/package_config.json`. + +Hence, it should never be necessary to clear this cache manually, if you find a +need to do please file a bug. diff --git a/.dart_tool/extension_discovery/vs_code.json b/.dart_tool/extension_discovery/vs_code.json new file mode 100644 index 0000000..e25437e --- /dev/null +++ b/.dart_tool/extension_discovery/vs_code.json @@ -0,0 +1 @@ +{"version":2,"entries":[{"package":"playWith","rootUri":"../","packageUri":"lib/"}]} \ No newline at end of file diff --git a/apps/app/android/app/build.gradle.kts b/apps/app/android/app/build.gradle.kts index 017a7c5..a065199 100644 --- a/apps/app/android/app/build.gradle.kts +++ b/apps/app/android/app/build.gradle.kts @@ -12,6 +12,7 @@ android { // [수정할 부분] compileOptions { + isCoreLibraryDesugaringEnabled = true // 기존 1.8 -> 17로 변경 sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -46,6 +47,12 @@ android { } } +dependencies { + // [추가] 디슈가링 라이브러리 추가 (필수) + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") +} + + flutter { source = "../.." } diff --git a/apps/app/android/app/src/main/AndroidManifest.xml b/apps/app/android/app/src/main/AndroidManifest.xml index d529e8f..d61ef2e 100644 --- a/apps/app/android/app/src/main/AndroidManifest.xml +++ b/apps/app/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,17 @@ + + + + + + + + + + + 2.2.1) + - CwlCatchExceptionSupport (2.2.1) - DKImagePickerController/Core (4.3.9): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource @@ -40,6 +43,11 @@ PODS: - DKImagePickerController/PhotoGallery - Flutter - Flutter (1.0.0) + - flutter_local_notifications (0.0.1): + - Flutter + - gal (1.0.0): + - Flutter + - FlutterMacOS - GoogleDataTransport (9.4.1): - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30911.0, >= 2.30908.0) @@ -102,6 +110,13 @@ PODS: - SDWebImage (5.21.1): - SDWebImage/Core (= 5.21.1) - SDWebImage/Core (5.21.1) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - speech_to_text (7.2.0): + - CwlCatchException + - Flutter + - FlutterMacOS - sqlite3 (3.50.4): - sqlite3/common (= 3.50.4) - sqlite3/common (3.50.4) @@ -134,14 +149,20 @@ DEPENDENCIES: - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - gal (from `.symlinks/plugins/gal/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`) - objective_c (from `.symlinks/plugins/objective_c/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`) - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) SPEC REPOS: trunk: + - CwlCatchException + - CwlCatchExceptionSupport - DKImagePickerController - DKPhotoGallery - GoogleDataTransport @@ -169,6 +190,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + gal: + :path: ".symlinks/plugins/gal/darwin" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" mobile_scanner: @@ -177,16 +202,24 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/objective_c/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + speech_to_text: + :path: ".symlinks/plugins/speech_to_text/darwin" sqlite3_flutter_libs: :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" SPEC CHECKSUMS: audioplayers_darwin: 4027b33a8f471d996c13f71cb77f0b1583b5d923 bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 + CwlCatchException: 7acc161b299a6de7f0a46a6ed741eae2c8b4d75a + CwlCatchExceptionSupport: 54ccab8d8c78907b57f99717fb19d4cc3bce02dc DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de + file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086 + gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleMLKit: 97ac7af399057e99182ee8edfa8249e3226a4065 GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 @@ -204,6 +237,8 @@ SPEC CHECKSUMS: permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 SDWebImage: f29024626962457f3470184232766516dee8dfea + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + speech_to_text: 87bf9298952e8d9073be1b6aade6d5758db5170c sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b sqlite3_flutter_libs: 86f82662868ee26ff3451f73cac9c5fc2a1f57fa SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 diff --git a/apps/app/ios/Runner/Info.plist b/apps/app/ios/Runner/Info.plist index a7e47ac..eea8f74 100644 --- a/apps/app/ios/Runner/Info.plist +++ b/apps/app/ios/Runner/Info.plist @@ -56,12 +56,16 @@ NSCameraUsageDescription QR 코드를 스캔하여 방에 접속하기 위해 카메라 권한이 필요합니다. NSPhotoLibraryUsageDescription - 채팅방에 사진을 공유하기 위해 갤러리 접근 권한이 필요합니다. - + 채팅방에 사진을 공유하기 위해 갤러리 접근 권한이 필요합니다. NSCameraUsageDescription - 사진을 찍어 공유하기 위해 카메라 권한이 필요합니다. - + 사진을 찍어 공유하기 위해 카메라 권한이 필요합니다. NSMicrophoneUsageDescription - 동영상 촬영을 위해 마이크 권한이 필요합니다. + 동영상 촬영을 위해 마이크 권한이 필요합니다. + NSPhotoLibraryAddUsageDescription + 이미지를 갤러리에 저장하기 위해 권한이 필요합니다. + NSMicrophoneUsageDescription + 정답을 음성으로 말하기 위해 마이크 권한이 필요합니다. + NSSpeechRecognitionUsageDescription + 말한 내용을 텍스트로 변환하여 정답을 확인합니다. diff --git a/apps/app/lib/intro/intro_screen.dart b/apps/app/lib/intro/intro_screen.dart new file mode 100644 index 0000000..59d22b6 --- /dev/null +++ b/apps/app/lib/intro/intro_screen.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:playwith_core/playwith_core.dart'; // SettingsNotifier +import 'intro_view.dart'; + +class IntroScreen extends StatelessWidget { + final WidgetBuilder nextScreenBuilder; + + const IntroScreen({ + super.key, + required this.nextScreenBuilder, + }); + + void _navigateToNextScreen(BuildContext context) { + // 인트로 종료 후 다음 화면(Lobby)으로 이동 (뒤로가기 불가) + Navigator.of(context).pushReplacement( + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => nextScreenBuilder(context), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition(opacity: animation, child: child); + }, + transitionDuration: const Duration(milliseconds: 800), // 부드러운 전환 + ), + ); + } + + @override + Widget build(BuildContext context) { + // SettingsNotifier 싱글톤에서 현재 색상 가져오기 + final Color currentColor = SettingsNotifier().currentColor; + + return Scaffold( + // 배경색은 테마 배경색 사용 + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: Center( + child: IntroViewFlutter( + mainColor: currentColor, + onAnimationFinished: () { + _navigateToNextScreen(context); + }, + ), + ), + ); + } +} \ No newline at end of file diff --git a/apps/app/lib/intro/intro_view.dart b/apps/app/lib/intro/intro_view.dart new file mode 100644 index 0000000..8b50d36 --- /dev/null +++ b/apps/app/lib/intro/intro_view.dart @@ -0,0 +1,194 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; + +/// "SBSPACE"를 한 줄로 그리는 IntroView +class IntroViewFlutter extends StatefulWidget { + final Color mainColor; + final VoidCallback onAnimationFinished; + + const IntroViewFlutter({ + super.key, + required this.mainColor, + required this.onAnimationFinished, + }); + + @override + State createState() => _IntroViewFlutterState(); +} + +class _IntroViewFlutterState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _logoTextAnimation; + late final Animation _missionTextAnimation; + + static const String _logoString = "SBSPACE"; + static const String _missionString = "Simple is Best."; + // [참고] 폰트가 없으면 기본 폰트로 나옵니다. 에셋에 폰트 추가가 필요할 수 있습니다. + static const String _fontFamily = "Sdmisaeng"; + + @override + void initState() { + super.initState(); + + const int logoDuration = _logoString.length * 150; + const int missionDuration = _missionString.length * 100; + final int totalAnimationDuration = logoDuration + missionDuration; + + _controller = AnimationController( + duration: Duration(milliseconds: totalAnimationDuration), + vsync: this, + ); + + _logoTextAnimation = IntTween(begin: 0, end: _logoString.length).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(0.0, logoDuration / totalAnimationDuration, curve: Curves.linear), + ), + ); + + _missionTextAnimation = IntTween(begin: 0, end: _missionString.length).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(logoDuration / totalAnimationDuration, 1.0, curve: Curves.linear), + ), + ); + + _controller.addStatusListener((status) { + if (status == AnimationStatus.completed) { + Future.delayed(const Duration(seconds: 1), () { + if (mounted) widget.onAnimationFinished(); + }); + } + }); + + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return CustomPaint( + painter: _IntroPainter( + mainColor: widget.mainColor, + fontFamily: _fontFamily, + logoTextLength: _logoTextAnimation.value, + missionTextLength: _missionTextAnimation.value, + ), + size: Size.infinite, + ); + }, + ); + } +} + +class _IntroPainter extends CustomPainter { + final Color mainColor; + final String fontFamily; + final int logoTextLength; + final int missionTextLength; + + static const String _logoString = "SBSPACE"; + static const String _missionString = "Simple is Best."; + + _IntroPainter({ + required this.mainColor, + required this.fontFamily, + required this.logoTextLength, + required this.missionTextLength, + }); + + TextSpan _buildLogoSpan(int length) { + final Color normalColor = mainColor.withOpacity(0.6); + final List children = []; + + if (length >= 1) children.add(TextSpan(text: "S", style: TextStyle(color: mainColor))); + if (length >= 2) children.add(TextSpan(text: "B", style: TextStyle(color: mainColor))); + if (length >= 3) { + final String spaceToDraw = _logoString.substring(2, length.clamp(2, _logoString.length)); + children.add(TextSpan(text: spaceToDraw, style: TextStyle(color: normalColor))); + } + + return TextSpan( + style: TextStyle(fontFamily: fontFamily, fontWeight: FontWeight.bold), + children: children, + ); + } + + TextPainter _createTextPainter(TextSpan textSpan, double fontSize) { + final style = textSpan.style!.copyWith(fontSize: fontSize); + final painter = TextPainter( + text: TextSpan(children: textSpan.children, style: style), + textDirection: TextDirection.ltr, + ); + painter.layout(); + return painter; + } + + @override + void paint(Canvas canvas, Size size) { + final double logoFontSize = size.shortestSide / 6.0; + + const double tempMissionFontSize = 100.0; + final TextPainter tpMissionTemp = TextPainter( + text: TextSpan( + text: _missionString, + style: TextStyle(fontFamily: fontFamily, fontWeight: FontWeight.bold, fontSize: tempMissionFontSize) + ), + textDirection: TextDirection.ltr, + )..layout(); + + final double targetWidth = size.width * 0.9; + final double scale = targetWidth / tpMissionTemp.width; + final double missionFontSize = tempMissionFontSize * scale; + + final tpLogoFull = _createTextPainter(_buildLogoSpan(_logoString.length), logoFontSize); + + final TextPainter tpMissionFull = TextPainter( + text: TextSpan( + text: _missionString, + style: TextStyle( + color: mainColor, + fontSize: missionFontSize, + fontFamily: fontFamily, + fontWeight: FontWeight.bold + ) + ), + textDirection: TextDirection.ltr, + )..layout(); + + final double padding = logoFontSize * 0.1; + final double totalHeight = tpLogoFull.height + padding + tpMissionFull.height; + final double startyLogo = (size.height - totalHeight) / 2.0; + final double startyMission = startyLogo + tpLogoFull.height + padding; + + final double startxLogo = (size.width - tpLogoFull.width) / 2.0; + final double startxMission = (size.width - tpMissionFull.width) / 2.0; + + final tpLogoSub = _createTextPainter(_buildLogoSpan(logoTextLength), logoFontSize); + tpLogoSub.paint(canvas, Offset(startxLogo, startyLogo)); + + final String missionToDraw = _missionString.substring(0, missionTextLength); + final tpMissionSub = TextPainter( + text: TextSpan(text: missionToDraw, style: tpMissionFull.text!.style), + textDirection: TextDirection.ltr, + )..layout(); + + tpMissionSub.paint(canvas, Offset(startxMission, startyMission)); + } + + @override + bool shouldRepaint(covariant _IntroPainter oldDelegate) { + return oldDelegate.mainColor != mainColor || + oldDelegate.logoTextLength != logoTextLength || + oldDelegate.missionTextLength != missionTextLength; + } +} \ No newline at end of file diff --git a/apps/app/lib/intro_screen.dart b/apps/app/lib/intro_screen.dart deleted file mode 100644 index 8351fd9..0000000 --- a/apps/app/lib/intro_screen.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'dart:io'; // Platform 확인용 -import 'package:flutter/material.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:playwith_core/playwith_core.dart'; -import 'lobby_screen.dart'; - -class IntroScreen extends StatefulWidget { - const IntroScreen({super.key}); - - @override - State createState() => _IntroScreenState(); -} - -class _IntroScreenState extends State { - final _nicknameController = TextEditingController(); - - Future _enterLobby() async { - if (_nicknameController.text.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("닉네임을 입력해주세요.")), - ); - return; - } - - // [수정됨] 플랫폼별 권한 분기 처리 - if (Platform.isAndroid) { - // 🤖 안드로이드: 명시적 권한 요청 필요 - Map statuses = await [ - Permission.location, // 안드로이드 12 이하 - Permission.nearbyWifiDevices, // 안드로이드 13 이상 - ].request(); - - // 로그 확인용 - bool isNearby = statuses[Permission.nearbyWifiDevices]?.isGranted ?? false; - bool isLocation = statuses[Permission.location]?.isGranted ?? false; - print("Android 권한 Check: Nearby=$isNearby, Location=$isLocation"); - - // 둘 다 거부되면 진행 불가 (단, 버전에 따라 하나만 있어도 됨) - // 여기서는 "둘 다 false일 때만" 막는 것으로 완화 - if (!isNearby && !isLocation) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("❌ 안드로이드는 권한이 필요합니다.")), - ); - return; - } - } else if (Platform.isIOS) { - // 🍎 iOS: 별도 요청 불필요 - // Info.plist에 설정만 잘 되어 있다면, - // NetworkManager가 start() 될 때 시스템이 알아서 물어봅니다. - print("iOS는 권한 체크를 건너뜁니다. (실행 시 자동 팝업됨)"); - } - - // 초기화 및 입장 - NetworkManager().initialize(nickname: _nicknameController.text.trim()); - - if (!mounted) return; - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const LobbyScreen()), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Padding( - padding: const EdgeInsets.all(32.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('PlayWith', style: TextStyle(fontSize: 40, fontWeight: FontWeight.bold)), - const SizedBox(height: 40), - TextField( - controller: _nicknameController, - decoration: const InputDecoration( - labelText: '닉네임을 입력하세요', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 20), - ElevatedButton( - onPressed: _enterLobby, - style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 50)), - child: const Text('입장하기'), - ), - ], - ), - ), - ), - ); - } -} \ No newline at end of file diff --git a/apps/app/lib/login_screen.dart b/apps/app/lib/login_screen.dart new file mode 100644 index 0000000..d17fa14 --- /dev/null +++ b/apps/app/lib/login_screen.dart @@ -0,0 +1,167 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:playwith_core/playwith_core.dart'; +import 'lobby_screen.dart'; +import 'screens/settings_screen.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _nicknameController = TextEditingController(); + final _settings = SettingsNotifier(); + + @override + void initState() { + super.initState(); + // 저장된 닉네임 반영 + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted && _settings.nickname.isNotEmpty) { + setState(() { + _nicknameController.text = _settings.nickname; + }); + } + }); + _settings.addListener(_syncSettings); + } + + @override + void dispose() { + _settings.removeListener(_syncSettings); + _nicknameController.dispose(); + super.dispose(); + } + + void _syncSettings() { + if (_nicknameController.text != _settings.nickname) { + if (mounted) { + setState(() { + _nicknameController.text = _settings.nickname; + }); + } + } + if (mounted) setState(() {}); + } + + Future _enterLobby() async { + final inputNick = _nicknameController.text.trim(); + if (inputNick.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("닉네임을 입력해주세요."))); + return; + } + + // 안드로이드 권한 체크 + if (Platform.isAndroid) { + Map statuses = await [ + Permission.location, + Permission.nearbyWifiDevices, + ].request(); + + bool isNearby = statuses[Permission.nearbyWifiDevices]?.isGranted ?? false; + bool isLocation = statuses[Permission.location]?.isGranted ?? false; + if (!isNearby && !isLocation) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("⚠️ 권한 허용이 필요합니다."))); + } + } + + // 변경 사항 저장 + if (inputNick != _settings.nickname) { + await _settings.setProfile(inputNick, _settings.avatarIndex); + } + + // [수정] 초기화 시 닉네임과 이미지를 함께 전달 + NetworkManager().initialize( + nickname: _settings.nickname, + profileImage: _settings.profileImageBase64, // [추가] + ); + + if (!mounted) return; + Navigator.push(context, MaterialPageRoute(builder: (_) => const LobbyScreen())); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.settings, color: Colors.grey), + tooltip: "설정", + onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())), + ) + ], + ), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('PlayWith', style: TextStyle(fontSize: 40, fontWeight: FontWeight.bold)), + const SizedBox(height: 40), + + // [핵심] AvatarWidget 사용 (Core 컴포넌트) + ListenableBuilder( + listenable: _settings, + builder: (context, _) { + return GestureDetector( + onTap: () => _settings.pickProfileImage(), + child: Stack( + children: [ + AvatarWidget( + base64Image: _settings.profileImageBase64, + colorValue: Colors.primaries[_settings.avatarIndex % Colors.primaries.length].value, + nickname: _nicknameController.text, + size: 120, + ), + Positioned( + right: 0, bottom: 0, + child: Container( + padding: const EdgeInsets.all(8), + decoration: const BoxDecoration(color: Colors.blue, shape: BoxShape.circle), + child: const Icon(Icons.camera_alt, size: 20, color: Colors.white), + ), + ), + ], + ), + ); + }, + ), + + const SizedBox(height: 30), + + TextField( + controller: _nicknameController, + textAlign: TextAlign.center, + decoration: const InputDecoration( + labelText: '닉네임', + border: OutlineInputBorder(), + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + ), + + const SizedBox(height: 30), + + ElevatedButton( + onPressed: _enterLobby, + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: const Text('입장하기', style: TextStyle(fontSize: 18)), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/apps/app/lib/main.dart b/apps/app/lib/main.dart index 4d6d894..b267918 100644 --- a/apps/app/lib/main.dart +++ b/apps/app/lib/main.dart @@ -1,21 +1,23 @@ import 'package:flutter/material.dart'; import 'package:playwith_core/playwith_core.dart'; -import 'package:playwith_game_quiz/quiz_game.dart'; // 퀴즈 모듈 import -import 'intro_screen.dart'; +import 'package:playwith_game_quiz/quiz_game.dart'; +import 'login_screen.dart'; // [수정] 인트로 스크린 import (경로가 다르면 수정 필요) +import 'intro/intro_screen.dart'; // 만약 intro 폴더에 넣으셨다면 이 경로 사용 +import 'lobby_screen.dart'; -void main() { - // 1. 플러터 바인딩 초기화 +Future main() async { WidgetsFlutterBinding.ensureInitialized(); - - // 2. 사운드 리소스 주입 (여기가 핵심!) - // AssetSource는 'assets/' 접두어를 자동으로 붙이므로 그 하위 경로만 적습니다. + SoundManager().initialize(soundPaths: { SoundKey.bgm: 'audio/bgm.mp3', SoundKey.correct: 'audio/correct.mp3', SoundKey.wrong: 'audio/wrong.mp3', SoundKey.win: 'audio/win.mp3', + SoundKey.click: 'audio/correct.mp3', }); + await NotificationManager().initialize(); + runApp(const PlayWithApp()); } @@ -28,43 +30,53 @@ class PlayWithApp extends StatefulWidget { class _PlayWithAppState extends State { final _net = NetworkManager(); + final _settings = SettingsNotifier(); - // 등록된 게임 목록 final List _games = [ - QuizGame(), // 여기서 등록! + QuizGame(), ]; @override void initState() { super.initState(); - - // [전역 라우팅] 네트워크 메시지를 감시하다가 'GAME_START'가 오면 해당 게임 실행 _net.messageStream.listen((data) { - if (data['type'] == 'GAME_START') { - final String gameId = data['gameId']; - - // ID에 맞는 게임 찾기 - final game = _games.firstWhere( - (g) => g.id == gameId, - orElse: () => throw Exception("Game not found: $gameId") - ); - - // 게임 화면으로 이동 (네비게이터 키를 안 쓰고 있어서 간단히 처리 불가, 아래 설명 참조) - // 실제로는 GlobalKey를 쓰거나, 현재 context를 찾아야 함. - // MVP에서는 LobbyScreen 내부에서 처리하는 것이 안전함. - } + // 라우팅 로직... }); } @override Widget build(BuildContext context) { - return MaterialApp( - title: 'PlayWith', - theme: ThemeData( - primarySwatch: Colors.blue, - useMaterial3: true, - ), - home: const IntroScreen(), + return ListenableBuilder( + listenable: _settings, + builder: (context, child) { + return MaterialApp( + title: 'PlayWith', + theme: _settings.currentTheme, + themeMode: _settings.currentThemeMode, + builder: (context, child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(_settings.fontScale), + ), + child: child!, + ); + }, + // [핵심 수정] 앱 시작 시 IntroScreen을 먼저 보여줌 + // nextScreenBuilder를 통해 애니메이션 종료 후 갈 곳(Lobby) 지정 + home: IntroScreen( + nextScreenBuilder: (context) => const LoginScreen(), + ), + ); + }, ); } -} \ No newline at end of file +} + +// [팁] 인트로와 닉네임 입력(IntroScreen.dart의 기존 로직)을 연결하기 위한 래퍼 +// 기존에 있던 닉네임 입력 화면(IntroScreen)과 이름이 겹치므로, +// 기존의 닉네임 입력 화면은 'LoginScreen'이나 'NameInputScreen'으로 이름을 바꾸는 게 좋습니다. +// 만약 'IntroScreen' 파일이 닉네임 입력 화면이었다면, +// 이번에 만든 애니메이션 화면을 'SplashAnimationScreen' 등으로 이름을 지어서 구분해주세요. + +// 여기서는 이번에 만든 애니메이션 화면을 'IntroAnimationScreen'이라고 가정하고, +// 애니메이션이 끝나면 -> 닉네임 입력 화면(기존 IntroScreen) -> 로비 순서로 가는 게 자연스럽습니다. \ No newline at end of file diff --git a/apps/app/lib/screens/settings_screen.dart b/apps/app/lib/screens/settings_screen.dart new file mode 100644 index 0000000..2bf6657 --- /dev/null +++ b/apps/app/lib/screens/settings_screen.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:playwith_core/playwith_core.dart'; // AvatarWidget 포함됨 + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + final _nickController = TextEditingController(); + final _settings = SettingsNotifier(); + + @override + void initState() { + super.initState(); + _nickController.text = _settings.nickname; + } + + @override + void dispose() { + _nickController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("설정")), + body: ListenableBuilder( + listenable: _settings, + builder: (context, _) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // 1. 프로필 설정 섹션 + _buildSectionTitle("프로필 설정"), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + // 아바타 변경 영역 + GestureDetector( + onTap: () => _settings.pickProfileImage(), + child: Stack( + alignment: Alignment.bottomRight, + children: [ + AvatarWidget( + base64Image: _settings.profileImageBase64, + colorValue: Colors.primaries[_settings.avatarIndex % Colors.primaries.length].value, + nickname: _nickController.text, + size: 100, + ), + Container( + padding: const EdgeInsets.all(6), + decoration: const BoxDecoration(color: Colors.blue, shape: BoxShape.circle), + child: const Icon(Icons.edit, size: 16, color: Colors.white), + ), + ], + ), + ), + + if (_settings.profileImageBase64 != null) + TextButton( + onPressed: () => _settings.clearProfileImage(), + child: const Text("이미지 삭제 (기본값 사용)", style: TextStyle(color: Colors.red)), + ), + + const SizedBox(height: 20), + + // 닉네임 입력 + TextField( + controller: _nickController, + decoration: const InputDecoration( + labelText: "닉네임", + border: OutlineInputBorder(), + helperText: "게임에서 사용할 이름을 입력하세요.", + ), + onChanged: (val) => _settings.setProfile(val, _settings.avatarIndex), + ), + + const SizedBox(height: 10), + + // 기본 아바타 색상 선택 (이미지 없을 때 사용) + const Align(alignment: Alignment.centerLeft, child: Text("기본 배경색")), + const SizedBox(height: 5), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate(Colors.primaries.length, (index) { + final isSelected = _settings.avatarIndex == index; + return GestureDetector( + onTap: () => _settings.setProfile(_nickController.text, index), + child: Container( + margin: const EdgeInsets.only(right: 8), + width: 30, + height: 30, + decoration: BoxDecoration( + color: Colors.primaries[index], + shape: BoxShape.circle, + border: isSelected ? Border.all(color: Colors.black, width: 2) : null, + ), + child: isSelected ? const Icon(Icons.check, size: 16, color: Colors.white) : null, + ), + ); + }), + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 20), + + // 2. 디스플레이 설정 섹션 + _buildSectionTitle("화면 설정"), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + title: const Text("다크 모드"), + value: _settings.isDarkMode, + onChanged: (val) => _settings.toggleDarkMode(val), + ), + const Divider(), + + const Text("글자 크기", style: TextStyle(fontWeight: FontWeight.bold)), + Slider( + value: _settings.fontScale, + min: 0.8, + max: 1.5, + divisions: 7, + label: "${(_settings.fontScale * 100).toInt()}%", + onChanged: (val) => _settings.setFontScale(val), + ), + Text( + "이 크기로 보입니다.", + style: TextStyle(fontSize: 16 * _settings.fontScale), + ), + const Divider(), + + const Text("테마 색상", style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 10), + Wrap( + spacing: 10, + runSpacing: 10, + children: appColors.entries.map((entry) { + final isSelected = _settings.themeColorName == entry.key; + return GestureDetector( + onTap: () => _settings.setThemeColor(entry.key), + child: Container( + width: 40, height: 40, + decoration: BoxDecoration( + color: entry.value, + shape: BoxShape.circle, + border: isSelected ? Border.all(color: Colors.black, width: 3) : null, + boxShadow: [if(isSelected) const BoxShadow(blurRadius: 5, color: Colors.black26)], + ), + child: isSelected ? const Icon(Icons.check, color: Colors.white) : null, + ), + ); + }).toList(), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ); + } + + Widget _buildSectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(left: 8, bottom: 8), + child: Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)), + ); + } +} \ No newline at end of file diff --git a/apps/app/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/app/macos/Flutter/GeneratedPluginRegistrant.swift index 8460a12..038ec7c 100644 --- a/apps/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,14 +7,24 @@ import Foundation import audioplayers_darwin import bonsoir_darwin +import file_picker import file_selector_macos +import flutter_local_notifications +import gal import mobile_scanner +import shared_preferences_foundation +import speech_to_text import sqlite3_flutter_libs func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) } diff --git a/apps/app/pubspec.lock b/apps/app/pubspec.lock index af01178..730ead3 100644 --- a/apps/app/pubspec.lock +++ b/apps/app/pubspec.lock @@ -229,10 +229,10 @@ packages: dependency: transitive description: name: file_picker - sha256: "1bbf65dd997458a08b531042ec3794112a6c39c07c37ff22113d2e7e4f81d4e4" + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 url: "https://pub.dev" source: hosted - version: "6.2.1" + version: "8.3.7" file_selector_linux: dependency: transitive description: @@ -286,6 +286,30 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_local_notifications: + dependency: transitive + description: + name: flutter_local_notifications + sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + url: "https://pub.dev" + source: hosted + version: "17.2.4" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + url: "https://pub.dev" + source: hosted + version: "4.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + url: "https://pub.dev" + source: hosted + version: "7.2.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -304,6 +328,14 @@ packages: description: flutter source: sdk version: "0.0.0" + gal: + dependency: transitive + description: + name: gal + sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee" + url: "https://pub.dev" + source: hosted + version: "2.3.2" http: dependency: transitive description: @@ -384,6 +416,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -520,6 +560,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pedantic: + dependency: transitive + description: + name: pedantic + sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602" + url: "https://pub.dev" + source: hosted + version: "1.11.1" permission_handler: dependency: "direct main" description: @@ -630,6 +678,62 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b" + url: "https://pub.dev" + source: hosted + version: "2.4.17" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -643,6 +747,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + speech_to_text: + dependency: transitive + description: + name: speech_to_text + sha256: c07557664974afa061f221d0d4186935bea4220728ea9446702825e8b988db04 + url: "https://pub.dev" + source: hosted + version: "7.3.0" + speech_to_text_platform_interface: + dependency: transitive + description: + name: speech_to_text_platform_interface + sha256: a1935847704e41ee468aad83181ddd2423d0833abe55d769c59afca07adb5114 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + speech_to_text_windows: + dependency: transitive + description: + name: speech_to_text_windows + sha256: "2c9846d18253c7bbe059a276297ef9f27e8a2745dead32192525beb208195072" + url: "https://pub.dev" + source: hosted + version: "1.0.0+beta.8" sqlite3: dependency: transitive description: @@ -707,6 +835,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.6" + timezone: + dependency: transitive + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" typed_data: dependency: transitive description: diff --git a/apps/app/windows/flutter/generated_plugin_registrant.cc b/apps/app/windows/flutter/generated_plugin_registrant.cc index ede831a..d285588 100644 --- a/apps/app/windows/flutter/generated_plugin_registrant.cc +++ b/apps/app/windows/flutter/generated_plugin_registrant.cc @@ -9,7 +9,9 @@ #include #include #include +#include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -19,8 +21,12 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + GalPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GalPluginCApi")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + SpeechToTextWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SpeechToTextWindows")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); } diff --git a/apps/app/windows/flutter/generated_plugins.cmake b/apps/app/windows/flutter/generated_plugins.cmake index 2935c24..fd566e5 100644 --- a/apps/app/windows/flutter/generated_plugins.cmake +++ b/apps/app/windows/flutter/generated_plugins.cmake @@ -6,7 +6,9 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows bonsoir_windows file_selector_windows + gal permission_handler_windows + speech_to_text_windows sqlite3_flutter_libs ) diff --git a/packages/core/lib/manager/global_chat_manager.dart b/packages/core/lib/manager/global_chat_manager.dart index 6373c20..01bfb0c 100644 --- a/packages/core/lib/manager/global_chat_manager.dart +++ b/packages/core/lib/manager/global_chat_manager.dart @@ -1,14 +1,22 @@ import 'dart:async'; +import 'package:flutter/widgets.dart'; // AppLifecycleState import '../network/network_manager.dart'; import '../model/play_packet.dart'; +import 'notification_manager.dart'; // [New] class ChatMessage { + final String senderId; // [추가] 유저 조회용 final String senderName; final String text; final bool isMe; final DateTime timestamp; - ChatMessage(this.senderName, this.text, this.isMe) : timestamp = DateTime.now(); + ChatMessage({ + required this.senderId, // 생성자 추가 + required this.senderName, + required this.text, + required this.isMe, + }) : timestamp = DateTime.now(); } class GlobalChatManager { @@ -16,13 +24,11 @@ class GlobalChatManager { factory GlobalChatManager() => _instance; GlobalChatManager._internal(); - // UI가 구독할 스트림 final _messageController = StreamController>.broadcast(); Stream> get messageStream => _messageController.stream; final List _messages = []; - /// [NetworkManager]로부터 패킷을 전달받음 void onPacketReceived(PlayPacket packet) { if (packet.type != PacketType.chat) return; @@ -31,25 +37,50 @@ class GlobalChatManager { final text = data['text']; final isMe = packet.senderId == NetworkManager().me.id; - final chatMsg = ChatMessage(senderName, text, isMe); + final chatMsg = ChatMessage( + senderId: packet.senderId, // ID 저장 + senderName: senderName, + text: text, + isMe: isMe, + ); _messages.add(chatMsg); - // UI 갱신 _messageController.add(List.from(_messages)); + + if (!isMe) { + _checkBackgroundAndNotify(senderName, text); + } + } + + // [New] 백그라운드 체크 로직 + void _checkBackgroundAndNotify(String sender, String text) { + // WidgetsBinding을 통해 현재 앱 상태 확인 + final state = WidgetsBinding.instance.lifecycleState; + + // 앱이 꺼져있거나(paused), 비활성(inactive) 상태일 때 + if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive || state == AppLifecycleState.detached) { + NotificationManager().showNotification( + id: DateTime.now().millisecondsSinceEpoch % 10000, // 유니크 ID + title: sender, + body: text, + ); + } } - /// 메시지 전송 void sendMessage(String text) { if (text.trim().isEmpty) return; final myInfo = NetworkManager().me; - // 1. 내 화면에 즉시 추가 (나한테는 네트워크로 안 돌아오므로) - final myMsg = ChatMessage(myInfo.nickname, text, true); + final myMsg = ChatMessage( + senderId: myInfo.id, // 내 ID + senderName: myInfo.nickname, + text: text, + isMe: true, + ); _messages.add(myMsg); _messageController.add(List.from(_messages)); - // 2. 네트워크 전송 (PlayPacket 포장) final packet = PlayPacket( type: PacketType.chat, senderId: myInfo.id, diff --git a/packages/core/lib/manager/media_manager.dart b/packages/core/lib/manager/media_manager.dart index 5877feb..298bb4b 100644 --- a/packages/core/lib/manager/media_manager.dart +++ b/packages/core/lib/manager/media_manager.dart @@ -11,6 +11,29 @@ import '../database/ephemeral_database.dart'; import '../network/network_manager.dart'; import '../model/play_packet.dart'; +class _TransferState { + final String mediaId; + final String fileName; + final String senderId; + final String senderName; + final String type; + final int totalChunks; + final File tempFile; + final IOSink fileSink; + int receivedChunks = 0; + + _TransferState({ + required this.mediaId, + required this.fileName, + required this.senderId, + required this.senderName, + required this.type, + required this.totalChunks, + required this.tempFile, + required this.fileSink, + }); +} + class MediaManager { static final MediaManager _instance = MediaManager._internal(); factory MediaManager() => _instance; @@ -19,44 +42,54 @@ class MediaManager { EphemeralDatabase? _db; String? _currentRoomId; - // UI에서 갤러리 변경을 감지하기 위한 스트림 (DB 변경 시 자동 발동) + final Map _activeTransfers = {}; + Completer? _ackCompleter; + + // [설정] 안정성을 위해 16KB 사용 + static const int CHUNK_SIZE = 16 * 1024; + Stream> get galleryStream { if (_db == null) return const Stream.empty(); - return _db!.select(_db!.mediaItems).watch(); // watch()는 데이터 변경 시 자동 업데이트됨 + return _db!.select(_db!.mediaItems).watch(); } // --------------------------------------------------------------------------- - // [1] 초기화 및 정리 (Lifecycle) + // 초기화 및 정리 // --------------------------------------------------------------------------- - - /// 방 생성/입장 시 호출 (DB 생성) Future initialize(String roomId) async { - // 기존 DB가 열려있다면 정리 await cleanup(); - _currentRoomId = roomId; _db = await EphemeralDatabase.create(roomId); - print("[MediaManager] DB Initialized for Room: $roomId"); + + final tempDir = await getTemporaryDirectory(); + final roomDir = Directory('${tempDir.path}/rooms/$roomId'); + if (!await roomDir.exists()) { + await roomDir.create(recursive: true); + } + print("[MediaManager] Initialized. Storage: ${roomDir.path}"); } - /// 방 나갈 때 호출 (데이터 파괴) Future cleanup() async { if (_db != null) { await _db!.close(); _db = null; } - - // DB 파일 삭제 (흔적 지우기) + for (var state in _activeTransfers.values) { + await state.fileSink.close(); + } + _activeTransfers.clear(); + _ackCompleter = null; + if (_currentRoomId != null) { try { final dbFolder = await getApplicationDocumentsDirectory(); - // EphemeralDatabase.create에서 만든 파일명과 동일해야 함 - final file = File('${dbFolder.path}/room_$_currentRoomId.sqlite'); - - if (await file.exists()) { - await file.delete(); - print("[MediaManager] DB File Deleted 🔥"); - } + final dbFile = File('${dbFolder.path}/room_$_currentRoomId.sqlite'); + if (await dbFile.exists()) await dbFile.delete(); + + final tempDir = await getTemporaryDirectory(); + final roomDir = Directory('${tempDir.path}/rooms/$_currentRoomId'); + if (await roomDir.exists()) await roomDir.delete(recursive: true); + print("[MediaManager] Cleaned up 🔥"); } catch (e) { print("[MediaManager] Cleanup Error: $e"); } @@ -65,18 +98,23 @@ class MediaManager { } // --------------------------------------------------------------------------- - // [2] 미디어 전송 (Send) + // 미디어 전송 (Stop-and-Wait ARQ) // --------------------------------------------------------------------------- Future sendMedia({ required String filePath, - required String type, // 'IMAGE', 'AUDIO' + required String type, }) async { - if (_db == null) return; + if (_db == null || _currentRoomId == null) return; final myInfo = NetworkManager().me; final mediaId = const Uuid().v4(); - - // A. 내 로컬 DB에 먼저 저장 (내가 보낸 것도 보여야 하니까) + final file = File(filePath); + final fileName = filePath.split('/').last; + final int fileSize = await file.length(); + final int totalChunks = (fileSize / CHUNK_SIZE).ceil(); + + print("[MediaManager] Uploading $fileName ($totalChunks chunks) with ACK..."); + await _db!.insertMedia(MediaItemsCompanion( id: drift.Value(mediaId), senderId: drift.Value(myInfo.id), @@ -86,64 +124,156 @@ class MediaManager { createdAt: drift.Value(DateTime.now()), )); - // B. 파일 읽기 및 인코딩 (MVP: Base64) - // 주의: 대용량 동영상은 이 방식으로 보내면 앱 멈춤. (추후 Chunk 방식 개선 필요) - final file = File(filePath); - final fileBytes = await file.readAsBytes(); - final base64Data = base64Encode(fileBytes); - final fileName = filePath.split('/').last; - - // C. 패킷 전송 - final packet = PlayPacket( + // 헤더 전송 + await _sendPacketAndWaitAck(PlayPacket( type: PacketType.media, senderId: myInfo.id, + timestamp: DateTime.now().millisecondsSinceEpoch, payload: { - 'id': mediaId, + 'step': 'HEADER', + 'mediaId': mediaId, + 'fileName': fileName, 'senderName': myInfo.nickname, 'type': type, - 'data': base64Data, - 'fileName': fileName, + 'totalChunks': totalChunks, + 'fileSize': fileSize, }, - timestamp: DateTime.now().millisecondsSinceEpoch, - ); + )); + + // 청크 전송 + final raf = await file.open(); + try { + for (int i = 0; i < totalChunks; i++) { + int length = CHUNK_SIZE; + if (i == totalChunks - 1) { + length = fileSize - (i * CHUNK_SIZE); + } + + List bytes = await raf.read(length); + String base64Chunk = base64Encode(bytes); + + await _sendPacketAndWaitAck(PlayPacket( + type: PacketType.media, + senderId: myInfo.id, + timestamp: DateTime.now().millisecondsSinceEpoch, + payload: { + 'step': 'CHUNK', + 'mediaId': mediaId, + 'index': i, + 'data': base64Chunk, + }, + )); + } + } finally { + await raf.close(); + } + print("[MediaManager] Upload Complete: $fileName"); + } + + Future _sendPacketAndWaitAck(PlayPacket packet) async { + _ackCompleter = Completer(); NetworkManager().sendPacket(packet); - print("[MediaManager] Sent media: $fileName"); + try { + // [설정] 타임아웃 30초로 증가 + await _ackCompleter!.future.timeout(const Duration(seconds: 30)); + } catch (e) { + print("[MediaManager] ACK Timeout! 전송 실패 가능성 있음."); + } } // --------------------------------------------------------------------------- - // [3] 미디어 수신 (Receive) + // 패킷 수신 // --------------------------------------------------------------------------- Future onMediaReceived(PlayPacket packet) async { + final data = packet.payload as Map; + final String step = data['step']; + + if (step == 'ACK') { + if (_ackCompleter != null && !_ackCompleter!.isCompleted) { + _ackCompleter!.complete(); + } + return; + } + + if (packet.senderId == NetworkManager().me.id) return; if (_db == null) return; - try { - final data = packet.payload as Map; - final String base64Data = data['data']; - final String fileName = data['fileName']; - - // A. 임시 폴더에 파일 저장 - final tempDir = await getTemporaryDirectory(); - // 파일명 충돌 방지를 위해 UUID나 Timestamp 붙여도 됨 - final savePath = '${tempDir.path}/${const Uuid().v4()}_$fileName'; - - final bytes = base64Decode(base64Data); - await File(savePath).writeAsBytes(bytes); + final String mediaId = data['mediaId']; - // B. DB에 메타데이터 저장 -> watch() 중인 UI가 자동 업데이트됨 - await _db!.insertMedia(MediaItemsCompanion( - id: drift.Value(data['id']), - senderId: drift.Value(packet.senderId), - senderName: drift.Value(data['senderName']), - type: drift.Value(data['type']), - filePath: drift.Value(savePath), - createdAt: drift.Value(DateTime.fromMillisecondsSinceEpoch(packet.timestamp)), - )); - - print("[MediaManager] File Saved: $savePath"); + try { + if (step == 'HEADER') { + final String fileName = data['fileName']; + final tempDir = await getTemporaryDirectory(); + final savePath = '${tempDir.path}/rooms/$_currentRoomId/${const Uuid().v4()}_$fileName'; + + final file = File(savePath); + await file.create(recursive: true); + final sink = file.openWrite(); + + _activeTransfers[mediaId] = _TransferState( + mediaId: mediaId, + fileName: fileName, + senderId: packet.senderId, + senderName: data['senderName'], + type: data['type'], + totalChunks: data['totalChunks'], + tempFile: file, + fileSink: sink, + ); + + print("[MediaManager] Recv Header. Sending ACK."); + _sendAck(mediaId); + } + else if (step == 'CHUNK') { + final state = _activeTransfers[mediaId]; + if (state == null) return; + + final String base64Data = data['data']; + final List bytes = base64Decode(base64Data); + + state.fileSink.add(bytes); + state.receivedChunks++; + + _sendAck(mediaId); + + if (state.receivedChunks >= state.totalChunks) { + await _finishTransfer(state); + } + } } catch (e) { print("[MediaManager] Receive Error: $e"); } } + + void _sendAck(String mediaId) { + final myInfo = NetworkManager().me; + NetworkManager().sendPacket(PlayPacket( + type: PacketType.media, + senderId: myInfo.id, + timestamp: DateTime.now().millisecondsSinceEpoch, + payload: { + 'step': 'ACK', + 'mediaId': mediaId, + }, + )); + } + + Future _finishTransfer(_TransferState state) async { + await state.fileSink.flush(); + await state.fileSink.close(); + + await _db!.insertMedia(MediaItemsCompanion( + id: drift.Value(state.mediaId), + senderId: drift.Value(state.senderId), + senderName: drift.Value(state.senderName), + type: drift.Value(state.type), + filePath: drift.Value(state.tempFile.path), + createdAt: drift.Value(DateTime.now()), + )); + + _activeTransfers.remove(state.mediaId); + print("[MediaManager] File Download Complete: ${state.fileName}"); + } } \ No newline at end of file diff --git a/packages/core/lib/manager/notification_manager.dart b/packages/core/lib/manager/notification_manager.dart new file mode 100644 index 0000000..57d5250 --- /dev/null +++ b/packages/core/lib/manager/notification_manager.dart @@ -0,0 +1,70 @@ +import 'dart:typed_data'; // [추가] Int64List 사용을 위해 필요 +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +class NotificationManager { + static final NotificationManager _instance = NotificationManager._internal(); + factory NotificationManager() => _instance; + NotificationManager._internal(); + + final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); + + bool _isInitialized = false; + + Future initialize() async { + if (_isInitialized) return; + + const AndroidInitializationSettings initializationSettingsAndroid = + AndroidInitializationSettings('@mipmap/ic_launcher'); + + const DarwinInitializationSettings initializationSettingsDarwin = + DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, // iOS는 사운드 권한이 곧 진동 권한과 연결됨 + ); + + const InitializationSettings initializationSettings = InitializationSettings( + android: initializationSettingsAndroid, + iOS: initializationSettingsDarwin, + ); + + await _flutterLocalNotificationsPlugin.initialize(initializationSettings); + _isInitialized = true; + } + + Future showNotification({ + required int id, + required String title, + required String body, + String? payload, + }) async { + + // [핵심] 진동 패턴 정의 (대기 -> 진동 -> 대기 -> 진동 ... 밀리초 단위) + // 예: 0ms 대기 후, 1000ms(1초) 진동, 500ms 쉬고, 1000ms 진동 + final Int64List vibrationPattern = Int64List.fromList([0, 1000, 500, 1000]); + + final AndroidNotificationDetails androidPlatformChannelSpecifics = + AndroidNotificationDetails( + 'playwith_channel_id_v2', // [중요] 설정을 바꾸면 채널 ID도 바꿔야 적용됨 (기존 ID는 설정 유지됨) + 'PlayWith Alarms', + channelDescription: '게임 및 채팅 알림 (진동 포함)', + importance: Importance.max, // 소리+진동을 위해 Max 필수 + priority: Priority.high, // 헤드업 알림을 위해 High 필수 + enableVibration: true, // 진동 켜기 + vibrationPattern: vibrationPattern, // 패턴 적용 + playSound: true, // 소리도 같이 + ); + + final NotificationDetails platformChannelSpecifics = + NotificationDetails(android: androidPlatformChannelSpecifics); + + await _flutterLocalNotificationsPlugin.show( + id, + title, + body, + platformChannelSpecifics, + payload: payload, + ); + } +} \ No newline at end of file diff --git a/packages/core/lib/manager/settings_manager.dart b/packages/core/lib/manager/settings_manager.dart new file mode 100644 index 0000000..08f5e2f --- /dev/null +++ b/packages/core/lib/manager/settings_manager.dart @@ -0,0 +1,149 @@ +import 'dart:convert'; // Base64용 +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; // 이미지 피커 +import 'package:shared_preferences/shared_preferences.dart'; + +// 1. 앱에서 사용할 색상표 정의 (Core에 둡니다) +final Map appColors = { + 'Blue': Colors.blue, + 'Green': Colors.green, + 'Red': Colors.red, + 'Purple': Colors.purple, + 'Orange': Colors.orange, + 'Teal': Colors.teal, + 'Pink': Colors.pink, + 'Amber': Colors.amber, +}; + +class SettingsNotifier with ChangeNotifier { + static final SettingsNotifier _instance = SettingsNotifier._internal(); + factory SettingsNotifier() => _instance; + SettingsNotifier._internal() { + _loadSettings(); + } + + // --- 저장 키 (Keys) --- + static const String _keyNickname = 'nickname'; + static const String _keyAvatarIdx = 'avatar_index'; + static const String _keyThemeColor = 'theme_color'; + static const String _keyDarkMode = 'is_dark_mode'; + static const String _keyFontScale = 'font_scale'; + static const String _keyProfileImage = 'profile_image_base64'; // [추가] 키 + + + // --- 상태 변수 (State) --- + String _nickname = ""; + int _avatarIndex = 0; + String _themeColorName = 'Blue'; + bool _isDarkMode = false; + double _fontScale = 1.0; // 1.0 = 기본, 0.8 = 작게, 1.5 = 크게 + String? _profileImageBase64; // [추가] 상태 변수 + + // --- Getters --- + String get nickname => _nickname; + int get avatarIndex => _avatarIndex; + String get themeColorName => _themeColorName; + bool get isDarkMode => _isDarkMode; + double get fontScale => _fontScale; + String? get profileImageBase64 => _profileImageBase64; // Getter 추가 + + MaterialColor get currentColor => appColors[_themeColorName] ?? Colors.blue; + + // 테마 데이터 생성 (Main에서 사용) + ThemeData get currentTheme { + final base = ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: currentColor, + brightness: _isDarkMode ? Brightness.dark : Brightness.light, + ), + brightness: _isDarkMode ? Brightness.dark : Brightness.light, + ); + + // 폰트 사이즈 적용 안된 버전 반환 (TextTheme은 Builder에서 MediaQuery로 적용하는 게 더 깔끔함) + return base; + } + + ThemeMode get currentThemeMode => _isDarkMode ? ThemeMode.dark : ThemeMode.light; + + // --- Methods --- + + Future _loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + _nickname = prefs.getString(_keyNickname) ?? ""; + _avatarIndex = prefs.getInt(_keyAvatarIdx) ?? 0; + _themeColorName = prefs.getString(_keyThemeColor) ?? 'Blue'; + _isDarkMode = prefs.getBool(_keyDarkMode) ?? false; + _fontScale = prefs.getDouble(_keyFontScale) ?? 1.0; + _profileImageBase64 = prefs.getString(_keyProfileImage); // 로드 + notifyListeners(); + } + + // 프로필 설정 + // [수정] 프로필 텍스트/인덱스 설정 + Future setProfile(String nick, int avatarIdx) async { + _nickname = nick; + _avatarIndex = avatarIdx; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyNickname, nick); + await prefs.setInt(_keyAvatarIdx, avatarIdx); + } + + // [추가] 프로필 이미지 설정 (500x500 리사이징) + Future pickProfileImage() async { + final picker = ImagePicker(); + // maxWidth/maxHeight를 지정하면 알아서 리사이징 해줌 (비율 유지) + final XFile? image = await picker.pickImage( + source: ImageSource.gallery, + maxWidth: 500, + maxHeight: 500, + imageQuality: 70, // 용량 최적화 + ); + + if (image != null) { + final bytes = await File(image.path).readAsBytes(); + final base64String = base64Encode(bytes); + + _profileImageBase64 = base64String; + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyProfileImage, base64String); + } + } + + // [추가] 프로필 이미지 삭제 (기본 아바타로 복귀) + Future clearProfileImage() async { + _profileImageBase64 = null; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keyProfileImage); + } + + // 테마 색상 설정 + Future setThemeColor(String colorName) async { + if (!appColors.containsKey(colorName)) return; + _themeColorName = colorName; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyThemeColor, colorName); + } + + // 다크 모드 토글 + Future toggleDarkMode(bool value) async { + _isDarkMode = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyDarkMode, value); + } + + // 폰트 크기 설정 + Future setFontScale(double scale) async { + _fontScale = scale.clamp(0.8, 2.0); // 최소 0.8배 ~ 최대 2.0배 제한 + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_keyFontScale, _fontScale); + } +} \ No newline at end of file diff --git a/packages/core/lib/manager/voice_manager.dart b/packages/core/lib/manager/voice_manager.dart new file mode 100644 index 0000000..bc11414 --- /dev/null +++ b/packages/core/lib/manager/voice_manager.dart @@ -0,0 +1,100 @@ +import 'dart:async'; +import 'package:speech_to_text/speech_to_text.dart'; + +class VoiceManager { + static final VoiceManager _instance = VoiceManager._internal(); + factory VoiceManager() => _instance; + VoiceManager._internal(); + + final SpeechToText _speech = SpeechToText(); + bool _isAvailable = false; + + // 실시간 음성 인식 결과를 UI에 뿌려주는 스트림 + final _resultController = StreamController.broadcast(); + Stream get resultStream => _resultController.stream; + + // 현재 듣고 있는지 여부 + bool get isListening => _speech.isListening; + + /// 초기화 (앱 시작 시 또는 게임 진입 시 호출) + Future initialize() async { + if (_isAvailable) return true; + try { + _isAvailable = await _speech.initialize( + onStatus: (status) => print('[Voice] Status: $status'), + onError: (error) => print('[Voice] Error: $error'), + ); + return _isAvailable; + } catch (e) { + print("[Voice] Init Failed: $e"); + return false; + } + } + + /// 듣기 시작 (5초간 유지) + Future startListening({ + required Function(String result) onResult, + int listenForSeconds = 5 + }) async { + if (!_isAvailable) { + bool init = await initialize(); + if (!init) return; + } + + _speech.listen( + onResult: (result) { + // 실시간 결과를 스트림에 전송 (UI 표시용) + _resultController.add(result.recognizedWords); + + // 최종 결과가 확정되면 콜백 호출 + if (result.finalResult) { + onResult(result.recognizedWords); + } + }, + listenFor: Duration(seconds: listenForSeconds), + localeId: "ko_KR", // 한국어 강제 (필요시 설정에서 가져오도록 변경) + cancelOnError: true, + partialResults: true, // 말하는 도중에도 결과 받기 + ); + } + + /// 듣기 중단 + Future stopListening() async { + await _speech.stop(); + } + + // --------------------------------------------------------------------------- + // [정답 판독기] 퍼지 매칭 (Fuzzy Matching) + // --------------------------------------------------------------------------- + /// 사용자가 말한 것(input)이 정답(answer)과 얼마나 비슷한지 체크 + /// 반환값: 정답 여부 (true/false) + bool checkAnswer(String input, String answer, {double threshold = 0.8}) { + final cleanInput = _normalize(input); + final cleanAnswer = _normalize(answer); + + // 1. 완전 일치 + if (cleanInput == cleanAnswer) return true; + + // 2. 포함 관계 ("이순신 장군" -> "이순신") + if (cleanInput.contains(cleanAnswer)) return true; + + // 3. 유사도 검사 (Jaccard Similarity 간이 구현) + // 글자 단위로 쪼개서 얼마나 겹치는지 확인 + final similarity = _calculateSimilarity(cleanInput, cleanAnswer); + print("[Voice] '$input' vs '$answer' -> Similarity: $similarity"); + + return similarity >= threshold; + } + + String _normalize(String text) { + return text.replaceAll(RegExp(r'\s+'), '').toLowerCase(); // 공백 제거, 소문자 + } + + double _calculateSimilarity(String s1, String s2) { + final set1 = s1.split('').toSet(); + final set2 = s2.split('').toSet(); + final intersection = set1.intersection(set2).length; + final union = set1.union(set2).length; + return intersection / union; + } +} \ No newline at end of file diff --git a/packages/core/lib/model/user_info.dart b/packages/core/lib/model/user_info.dart index bb3f73b..7cdb093 100644 --- a/packages/core/lib/model/user_info.dart +++ b/packages/core/lib/model/user_info.dart @@ -5,14 +5,16 @@ class UserInfo extends Equatable { final String nickname; final int avatarIndex; final int colorValue; - final bool isReady; // [추가] 준비 상태 + final bool isReady; + final String? profileImageBase64; // [추가] 커스텀 프로필 이미지 (Base64) const UserInfo({ required this.id, required this.nickname, this.avatarIndex = 0, this.colorValue = 0xFF2196F3, - this.isReady = false, // 기본값 false + this.isReady = false, + this.profileImageBase64, // 생성자 추가 }); factory UserInfo.fromJson(Map json) { @@ -21,7 +23,8 @@ class UserInfo extends Equatable { nickname: json['nickname'] as String, avatarIndex: json['avatarIndex'] as int? ?? 0, colorValue: json['colorValue'] as int? ?? 0xFF2196F3, - isReady: json['isReady'] as bool? ?? false, // JSON 파싱 추가 + isReady: json['isReady'] as bool? ?? false, + profileImageBase64: json['profileImageBase64'] as String?, // 파싱 추가 ); } @@ -31,7 +34,8 @@ class UserInfo extends Equatable { 'nickname': nickname, 'avatarIndex': avatarIndex, 'colorValue': colorValue, - 'isReady': isReady, // JSON 변환 추가 + 'isReady': isReady, + 'profileImageBase64': profileImageBase64, // 변환 추가 }; } @@ -40,7 +44,8 @@ class UserInfo extends Equatable { String? nickname, int? avatarIndex, int? colorValue, - bool? isReady, // copyWith 추가 + bool? isReady, + String? profileImageBase64, // copyWith 추가 }) { return UserInfo( id: id ?? this.id, @@ -48,9 +53,10 @@ class UserInfo extends Equatable { avatarIndex: avatarIndex ?? this.avatarIndex, colorValue: colorValue ?? this.colorValue, isReady: isReady ?? this.isReady, + profileImageBase64: profileImageBase64 ?? this.profileImageBase64, ); } @override - List get props => [id, nickname, avatarIndex, colorValue, isReady]; + List get props => [id, nickname, avatarIndex, colorValue, isReady, profileImageBase64]; } \ No newline at end of file diff --git a/packages/core/lib/network/network_manager.dart b/packages/core/lib/network/network_manager.dart index d13caf0..e67e7b4 100644 --- a/packages/core/lib/network/network_manager.dart +++ b/packages/core/lib/network/network_manager.dart @@ -7,11 +7,11 @@ import 'package:bonsoir/bonsoir.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:uuid/uuid.dart'; - +import '../manager/notification_manager.dart'; import '../model/user_info.dart'; import '../model/play_packet.dart'; import '../manager/global_chat_manager.dart'; -import '../manager/media_manager.dart'; // [New] 미디어 매니저 +import '../manager/media_manager.dart'; enum NetworkRole { none, host, guest } @@ -23,6 +23,16 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); } + // ------------------------------------------------------------------------ + // 상수 설정 + // ------------------------------------------------------------------------ + // [핵심] 패킷 구분자 (End Of Packet) - Base64와 겹치지 않는 고유 문자열 + static const String PACKET_DELIMITER = "|||EOP|||"; + + static const int HEARTBEAT_INTERVAL_SEC = 3; + static const int TIMEOUT_SEC = 10; + static const int RECONNECT_WAIT_SEC = 5; + // ------------------------------------------------------------------------ // 상태 변수 // ------------------------------------------------------------------------ @@ -35,41 +45,43 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { ServerSocket? _serverSocket; Socket? _clientSocket; - // 소켓과 유저 정보를 1:1 매핑 final Map _connectedGuests = {}; - - // UI 표시용 게스트 명단 final List guestList = []; + // [버퍼] 소켓별로 들어오다 만 데이터를 저장 + final Map _packetBuffers = {}; + BonsoirService? _bonsoirService; BonsoirBroadcast? _bonsoirBroadcast; BonsoirDiscovery? _bonsoirDiscovery; - // 게임 데이터 스트림 final _messageController = StreamController>.broadcast(); Stream> get messageStream => _messageController.stream; - // 로그 스트림 final _logController = StreamController.broadcast(); Stream get logStream => _logController.stream; - // 하트비트 & 재접속 Timer? _heartbeatTimer; Timer? _disconnectWaitTimer; DateTime? _lastPongTime; bool _isReconnecting = false; - static const int HEARTBEAT_INTERVAL_SEC = 3; - static const int TIMEOUT_SEC = 10; - static const int RECONNECT_WAIT_SEC = 5; - // ------------------------------------------------------------------------ // 초기화 // ------------------------------------------------------------------------ - void initialize({required String nickname}) { + void initialize({ + required String nickname, + String? profileImage, // [추가] 선택적 파라미터 + }) { final uuid = const Uuid().v4().substring(0, 8); final randomColor = 0xFF000000 | (nickname.hashCode & 0xFFFFFF); - me = UserInfo(id: uuid, nickname: nickname, colorValue: randomColor); + + me = UserInfo( + id: uuid, + nickname: nickname, + colorValue: randomColor, + profileImageBase64: profileImage, // [적용] + ); _log("초기화 완료: ${me.nickname}"); } @@ -161,7 +173,6 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { _bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!); await _bonsoirBroadcast!.start(); - // [NEW] 미디어 DB 초기화 (Host) await MediaManager().initialize(roomName); _startHeartbeat(); @@ -176,6 +187,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { void _handleNewGuest(Socket client) { _log("🎉 연결됨: ${client.remoteAddress.address}"); _connectedGuests[client] = null; + _packetBuffers[client] = ""; // 버퍼 초기화 client.listen( (Uint8List data) => _onDataReceived(client, data), @@ -191,6 +203,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { guestList.removeWhere((u) => u.id == user.id); } _connectedGuests.remove(client); + _packetBuffers.remove(client); client.close(); notifyListeners(); } @@ -240,10 +253,11 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { _log("🚀 접속 시도: $ip:$port"); _clientSocket = await Socket.connect(ip, port, timeout: const Duration(seconds: 5)); _log("✅ 접속 성공!"); + + _packetBuffers[_clientSocket!] = ""; // 내 버퍼 초기화 sendMessage({'type': 'HANDSHAKE', 'payload': me.toJson()}); - // [NEW] 미디어 DB 초기화 (Guest는 임시 ID 사용) await MediaManager().initialize("guest_${ip.replaceAll('.', '_')}"); _lastPongTime = DateTime.now(); @@ -265,7 +279,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { } // ------------------------------------------------------------------------ - // 데이터 송수신 & 라우팅 (핵심) + // 데이터 송수신 (버퍼링 및 구분자 로직 강화) // ------------------------------------------------------------------------ void sendPacket(PlayPacket packet) { sendMessage(packet.toJson()); @@ -275,6 +289,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { if (role == NetworkRole.guest && _clientSocket == null) return; final jsonString = jsonEncode(messageMap); + // 로그 필터링 if (messageMap['type'] != 'PING' && messageMap['type'] != 'PONG') { if (messageMap['type'] == 'chat') { @@ -286,7 +301,10 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { } } - final List data = utf8.encode('$jsonString\n'); + // [핵심] 메시지 뒤에 고유 구분자를 붙여서 전송 + final fullMessage = '$jsonString$PACKET_DELIMITER'; + final List data = utf8.encode(fullMessage); + if (role == NetworkRole.host) { for (var socket in _connectedGuests.keys) { socket.add(data); @@ -297,86 +315,98 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { } void _onDataReceived(Socket socket, Uint8List data) { - final String rawString = utf8.decode(data); - final List splitMessages = rawString.split('\n'); - - for (var msg in splitMessages) { - if (msg.trim().isEmpty) continue; - - try { - final Map jsonMap = jsonDecode(msg); + try { + // 1. 기존 버퍼 가져오기 + String buffer = _packetBuffers[socket] ?? ""; + // 2. 새 데이터 추가 + buffer += utf8.decode(data, allowMalformed: true); - // 1. 시스템 메시지 (Ping/Pong) - if (jsonMap['type'] == 'PING') { - sendMessage({'type': 'PONG'}); - _lastPongTime = DateTime.now(); - return; - } - if (jsonMap['type'] == 'PONG') { - _lastPongTime = DateTime.now(); - return; - } + // 3. 구분자(PACKET_DELIMITER)를 기준으로 메시지 추출 + while (buffer.contains(PACKET_DELIMITER)) { + final int delimiterIndex = buffer.indexOf(PACKET_DELIMITER); - // 2. 핸드셰이크 - if (jsonMap['type'] == 'HANDSHAKE') { - final guestInfo = UserInfo.fromJson(jsonMap['payload']); - _connectedGuests[socket] = guestInfo; - guestList.removeWhere((u) => u.id == guestInfo.id); - guestList.add(guestInfo); - notifyListeners(); - _messageController.add(jsonMap); - return; - } - - // 3. 레디 토글 - if (jsonMap['type'] == 'TOGGLE_READY') { - final String userId = jsonMap['userId']; - final bool isReady = jsonMap['isReady']; - - final index = guestList.indexWhere((u) => u.id == userId); - if (index != -1) { - guestList[index] = guestList[index].copyWith(isReady: isReady); - notifyListeners(); - } - - if (role == NetworkRole.host) { - sendMessage(jsonMap); - _checkAllReadyAndStart(); - } - _messageController.add(jsonMap); - return; - } + // 완성된 메시지 하나 추출 + final String msg = buffer.substring(0, delimiterIndex); - // 4. 게임 시작 - if (jsonMap['type'] == 'GAME_START') { - _resetAllReadyState(); - _messageController.add(jsonMap); - return; + // 버퍼에서 추출한 부분과 구분자 제거 + buffer = buffer.substring(delimiterIndex + PACKET_DELIMITER.length); + + // 메시지 처리 + if (msg.trim().isNotEmpty) { + _processMessage(socket, msg); } - - // 5. 패킷 라우팅 (Chat, Media, Game) - if (jsonMap.containsKey('payload') && jsonMap.containsKey('senderId')) { - final packet = PlayPacket.fromJson(jsonMap); - - // [라우팅] 채팅 -> GlobalChatManager - if (packet.type == PacketType.chat) { - GlobalChatManager().onPacketReceived(packet); - return; - } - - // [라우팅] 미디어 -> MediaManager - if (packet.type == PacketType.media) { - MediaManager().onMediaReceived(packet); - return; - } - } - - // 6. 그 외 게임 데이터 - _messageController.add(jsonMap); - - } catch (e) { - _log("파싱 에러: $e"); } + + // 4. 남은 찌꺼기(다음 패킷의 일부)를 다시 버퍼에 저장 + _packetBuffers[socket] = buffer; + + } catch (e) { + _log("데이터 수신 에러: $e"); + } + } + + void _processMessage(Socket socket, String msg) { + try { + final Map jsonMap = jsonDecode(msg); + + if (jsonMap['type'] == 'PING') { + sendMessage({'type': 'PONG'}); + _lastPongTime = DateTime.now(); + return; + } + if (jsonMap['type'] == 'PONG') { + _lastPongTime = DateTime.now(); + return; + } + + if (jsonMap['type'] == 'HANDSHAKE') { + final guestInfo = UserInfo.fromJson(jsonMap['payload']); + _connectedGuests[socket] = guestInfo; + guestList.removeWhere((u) => u.id == guestInfo.id); + guestList.add(guestInfo); + notifyListeners(); + _messageController.add(jsonMap); + return; + } + + if (jsonMap['type'] == 'TOGGLE_READY') { + final String userId = jsonMap['userId']; + final bool isReady = jsonMap['isReady']; + final index = guestList.indexWhere((u) => u.id == userId); + if (index != -1) { + guestList[index] = guestList[index].copyWith(isReady: isReady); + notifyListeners(); + } + if (role == NetworkRole.host) { + sendMessage(jsonMap); + _checkAllReadyAndStart(); + } + _messageController.add(jsonMap); + return; + } + + if (jsonMap['type'] == 'GAME_START') { + _resetAllReadyState(); + _messageController.add(jsonMap); + return; + } + + if (jsonMap.containsKey('payload') && jsonMap.containsKey('senderId')) { + final packet = PlayPacket.fromJson(jsonMap); + if (packet.type == PacketType.chat) { + GlobalChatManager().onPacketReceived(packet); + return; + } + if (packet.type == PacketType.media) { + MediaManager().onMediaReceived(packet); + return; + } + } + + _messageController.add(jsonMap); + + } catch (e) { + _log("JSON 파싱 실패: $e"); } } @@ -385,6 +415,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { // ------------------------------------------------------------------------ void _handleConnectionLost(dynamic reason) { if (role != NetworkRole.guest) return; + _log("⚠️ 연결 끊김: $reason"); _clientSocket?.destroy(); @@ -392,13 +423,32 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { if (_disconnectWaitTimer != null && _disconnectWaitTimer!.isActive) return; + // 5초 카운트다운 시작 _disconnectWaitTimer = Timer(const Duration(seconds: RECONNECT_WAIT_SEC), () { _log("💀 복구 실패. 종료."); + + // [추가] 재접속 실패 알림 발송 + _sendDisconnectionNotification(); + stopNetwork(force: true); }); + _attemptReconnection(); } + // [신규] 연결 끊김 알림 메서드 + void _sendDisconnectionNotification() { + // 앱이 현재 화면에 떠있지 않을 때만(백그라운드) 알림 + final state = WidgetsBinding.instance.lifecycleState; + if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive || state == AppLifecycleState.detached) { + NotificationManager().showNotification( + id: 9999, // 고정 ID (시스템 알림용) + title: "연결 끊김 ⚠️", + body: "방과의 연결이 종료되었습니다. 앱을 실행해 확인해주세요.", + ); + } + } + Future _attemptReconnection() async { if (hostIp == null || hostPort == null) return; _isReconnecting = true; @@ -451,7 +501,6 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { if (!force && _disconnectWaitTimer != null) return; _log("🛑 종료"); - // [NEW] 미디어 DB 정리 MediaManager().cleanup(); _heartbeatTimer?.cancel(); @@ -461,9 +510,12 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver { _bonsoirDiscovery?.stop(); _serverSocket?.close(); _clientSocket?.close(); + for (var s in _connectedGuests.keys) s.close(); _connectedGuests.clear(); + _packetBuffers.clear(); // 버퍼 정리 guestList.clear(); + role = NetworkRole.none; _serverSocket = null; _clientSocket = null; diff --git a/packages/core/lib/playwith_core.dart b/packages/core/lib/playwith_core.dart index 96e7a8d..28efa39 100644 --- a/packages/core/lib/playwith_core.dart +++ b/packages/core/lib/playwith_core.dart @@ -6,9 +6,13 @@ export 'model/user_info.dart'; export 'model/play_packet.dart'; export 'utils/sound_manager.dart'; export 'manager/global_chat_manager.dart'; -export 'widgets/game_chat_overlay.dart'; - -// [추가] DB 관련 (Drift가 생성한 데이터 클래스들도 쓰기 위해) +export 'manager/media_manager.dart'; +export 'manager/settings_manager.dart'; export 'database/ephemeral_database.dart'; -// Drift의 기본 타입(Value 등)을 쓰려면 아래 줄도 필요할 수 있음 (선택) -export 'package:drift/drift.dart' show Value; \ No newline at end of file + +// [Widget] +export 'widgets/game_chat_overlay.dart'; +export 'widgets/avatar_widget.dart'; // [추가됨] +export 'manager/voice_manager.dart'; +export 'widgets/voice_widget.dart'; +export 'manager/notification_manager.dart'; // 추가 \ No newline at end of file diff --git a/packages/core/lib/widgets/avatar_widget.dart b/packages/core/lib/widgets/avatar_widget.dart new file mode 100644 index 0000000..1d6620e --- /dev/null +++ b/packages/core/lib/widgets/avatar_widget.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import '../model/user_info.dart'; // Core 내부 참조 + +class AvatarWidget extends StatelessWidget { + final UserInfo? user; // UserInfo 객체가 있을 때 + final String? base64Image; // 객체 없이 이미지 데이터만 있을 때 (설정 화면 등) + final int colorValue; // 기본 색상 + final String nickname; // 기본 닉네임 + final double size; + + const AvatarWidget({ + super.key, + this.user, + this.base64Image, + this.colorValue = 0xFF2196F3, + this.nickname = "?", + this.size = 50, + }); + + @override + Widget build(BuildContext context) { + // 1. 우선순위: UserInfo > 직접 입력된 값 + String? img = user?.profileImageBase64 ?? base64Image; + int color = user?.colorValue ?? colorValue; + String name = user?.nickname ?? nickname; + if (name.isEmpty) name = "?"; + + ImageProvider? imageProvider; + + // 2. Base64 이미지 디코딩 + if (img != null && img.isNotEmpty) { + try { + Uint8List bytes = base64Decode(img); + imageProvider = MemoryImage(bytes); + } catch (e) { + debugPrint("Avatar decode error: $e"); + } + } + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: imageProvider != null ? null : Color(color), + image: imageProvider != null + ? DecorationImage(image: imageProvider, fit: BoxFit.cover) + : null, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ) + ], + ), + child: imageProvider == null + ? Center( + child: Text( + name.isNotEmpty ? name[0] : "?", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: size * 0.5 + ), + ), + ) + : null, + ); + } +} \ No newline at end of file diff --git a/packages/core/lib/widgets/game_chat_overlay.dart b/packages/core/lib/widgets/game_chat_overlay.dart index a1dcf97..9d093cc 100644 --- a/packages/core/lib/widgets/game_chat_overlay.dart +++ b/packages/core/lib/widgets/game_chat_overlay.dart @@ -1,9 +1,14 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:gal/gal.dart'; import 'package:image_picker/image_picker.dart'; import '../manager/global_chat_manager.dart'; -import '../manager/media_manager.dart'; // 미디어 매니저 -import '../database/ephemeral_database.dart'; // DB 모델 +import '../manager/media_manager.dart'; +import '../database/ephemeral_database.dart'; +import '../network/network_manager.dart'; // UserInfo 조회를 위해 추가 +import '../model/user_info.dart'; +import 'avatar_widget.dart'; // AvatarWidget import class GameChatOverlay extends StatefulWidget { const GameChatOverlay({super.key}); @@ -15,17 +20,88 @@ class GameChatOverlay extends StatefulWidget { class _GameChatOverlayState extends State { final TextEditingController _textController = TextEditingController(); final ScrollController _scrollController = ScrollController(); + bool _isExpanded = false; + + int _unreadCount = 0; + String _latestPreview = "채팅에 참여해보세요!"; + + StreamSubscription? _chatSub; + StreamSubscription? _mediaSub; + int _lastChatLength = 0; + int _lastMediaLength = 0; + + @override + void initState() { + super.initState(); + + _chatSub = GlobalChatManager().messageStream.listen((messages) { + if (messages.isEmpty) return; + if (messages.length > _lastChatLength) { + final lastMsg = messages.last; + if (!_isExpanded && mounted) { + setState(() { + _unreadCount++; + _latestPreview = "${lastMsg.senderName}: ${lastMsg.text}"; + }); + } + } + _lastChatLength = messages.length; + }); + + _mediaSub = MediaManager().galleryStream.listen((mediaList) { + if (mediaList.isEmpty) return; + if (mediaList.length > _lastMediaLength) { + final lastMedia = mediaList.last; + if (!_isExpanded && mounted) { + setState(() { + _unreadCount++; + _latestPreview = "📷 ${lastMedia.senderName}님이 사진을 보냈습니다."; + }); + } + } + _lastMediaLength = mediaList.length; + }); + } + + @override + void dispose() { + _chatSub?.cancel(); + _mediaSub?.cancel(); + _textController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _toggleExpand() { + setState(() { + _isExpanded = !_isExpanded; + if (_isExpanded) { + _unreadCount = 0; + _latestPreview = ""; + } + }); + } @override Widget build(BuildContext context) { + // [핵심 수정] 키보드가 올라왔을 때 그 높이만큼 값을 가져옴 + final bottomPadding = MediaQuery.of(context).viewInsets.bottom; + return Align( alignment: Alignment.bottomCenter, child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - // 높이 조정: 미디어 갤러리가 보일 공간 확보 (펼쳤을 때) - height: _isExpanded ? 500 : 60, - margin: const EdgeInsets.all(10), + duration: const Duration(milliseconds: 200), // 반응 속도를 위해 조금 빠르게 조정 + height: _isExpanded ? 500 : 60, + + // [핵심 수정] 기존 마진(10)에 키보드 높이(bottomPadding)를 더해줌 + // 이렇게 하면 키보드가 올라올 때 채팅창도 같이 올라갑니다. + margin: EdgeInsets.only( + left: 10, + right: 10, + bottom: 10 + bottomPadding, + ), + decoration: BoxDecoration( color: Colors.black.withOpacity(0.85), borderRadius: BorderRadius.circular(20), @@ -33,59 +109,97 @@ class _GameChatOverlayState extends State { ), child: Column( children: [ - // 1. 상단 핸들 (접기/펼치기) + // 1. 상단 핸들 GestureDetector( - onTap: () => setState(() => _isExpanded = !_isExpanded), + onTap: _toggleExpand, behavior: HitTestBehavior.translucent, child: Container( width: double.infinity, - height: 30, - alignment: Alignment.center, - child: Icon( - _isExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up, - color: Colors.white, + height: 60, + padding: const EdgeInsets.symmetric(horizontal: 20), + alignment: Alignment.centerLeft, + child: Row( + children: [ + Icon( + _isExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up, + color: Colors.white70, + ), + const SizedBox(width: 10), + + if (!_isExpanded) + Expanded( + child: Text( + _latestPreview, + style: const TextStyle(color: Colors.white, fontSize: 14), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ) + else + const Text("채팅 및 미디어", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)), + + if (!_isExpanded && _unreadCount > 0) + Container( + margin: const EdgeInsets.only(left: 10), + padding: const EdgeInsets.all(6), + decoration: const BoxDecoration(color: Colors.redAccent, shape: BoxShape.circle), + child: Text("$_unreadCount", style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)), + ), + ], ), ), ), - // 펼쳤을 때만 보이는 영역 + // 2. 내부 콘텐츠 if (_isExpanded) ...[ + const Divider(height: 1, color: Colors.white24), - // 2. 미디어 갤러리 (가로 스크롤) - // DB의 변경사항을 실시간으로 감지(Stream)하여 보여줌 - SizedBox( - height: 100, + // 미디어 갤러리 + Container( + height: 110, + width: double.infinity, + color: Colors.black12, child: StreamBuilder>( stream: MediaManager().galleryStream, + initialData: const [], builder: (context, snapshot) { - final mediaList = snapshot.data ?? []; + if (snapshot.hasError) return const Center(child: Icon(Icons.error, color: Colors.grey)); + final mediaList = snapshot.data ?? []; if (mediaList.isEmpty) { - return const Center(child: Text("공유된 미디어가 없습니다.", style: TextStyle(color: Colors.white54, fontSize: 12))); + return const Center(child: Text("공유된 사진이 없습니다.", style: TextStyle(color: Colors.white38, fontSize: 12))); } return ListView.builder( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 10), + padding: const EdgeInsets.all(10), itemCount: mediaList.length, itemBuilder: (context, index) { final item = mediaList[index]; return Padding( - padding: const EdgeInsets.only(right: 8.0), + padding: const EdgeInsets.only(right: 10), child: GestureDetector( onTap: () => _showFullImage(context, item), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.file( - File(item.filePath), - width: 100, - height: 100, - fit: BoxFit.cover, - errorBuilder: (_,__,___) => Container( - width: 100, height: 100, color: Colors.grey, - child: const Icon(Icons.broken_image), + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(item.filePath), + width: 70, height: 70, + fit: BoxFit.cover, + errorBuilder: (_,__,___) => Container( + width: 70, height: 70, color: Colors.grey[800], + child: const Icon(Icons.broken_image, color: Colors.white54), + ), + ), ), - ), + const SizedBox(height: 4), + Text( + item.senderName.length > 4 ? "${item.senderName.substring(0,4)}.." : item.senderName, + style: const TextStyle(color: Colors.white70, fontSize: 10), + ) + ], ), ), ); @@ -95,33 +209,68 @@ class _GameChatOverlayState extends State { ), ), - const Divider(color: Colors.white24), + const Divider(height: 1, color: Colors.white24), - // 3. 채팅 리스트 + // 채팅 리스트 + // 3. 채팅 리스트 부분 Expanded( child: StreamBuilder>( stream: GlobalChatManager().messageStream, builder: (context, snapshot) { final messages = snapshot.data ?? []; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); - } - }); + // ... (스크롤 로직 동일) ... + return ListView.builder( controller: _scrollController, - padding: const EdgeInsets.symmetric(horizontal: 10), + padding: const EdgeInsets.all(10), itemCount: messages.length, itemBuilder: (context, index) { final msg = messages[index]; + + // [핵심] 메시지 보낸 사람의 최신 정보 찾기 (이미지 표시용) + UserInfo? senderInfo; + if (msg.isMe) { + senderInfo = NetworkManager().me; + } else { + // 게스트 리스트에서 찾기 (나갔으면 null일 수 있음) + try { + senderInfo = NetworkManager().guestList.firstWhere((u) => u.id == msg.senderId); + } catch (_) {} + } + return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Text( - "${msg.senderName}: ${msg.text}", - style: TextStyle( - color: msg.isMe ? Colors.yellow : Colors.white, - fontSize: 14, - ), + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: msg.isMe ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!msg.isMe) ...[ + // [수정] AvatarWidget 적용 + AvatarWidget( + user: senderInfo, // 유저 정보가 있으면 이미지 자동 적용 + nickname: msg.senderName, // 없으면 이름 첫 글자 + size: 30, // 채팅창에 맞게 작게 + ), + const SizedBox(width: 8), + ], + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: msg.isMe ? Colors.blueAccent : Colors.white10, + borderRadius: BorderRadius.circular(15), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!msg.isMe) + Text(msg.senderName, style: const TextStyle(fontSize: 10, color: Colors.grey)), + Text(msg.text, style: const TextStyle(color: Colors.white)), + ], + ), + ), + ), + ], ), ); }, @@ -130,12 +279,12 @@ class _GameChatOverlayState extends State { ), ), - // 4. 입력창 (+ 미디어 버튼) - Padding( + // 입력창 + Container( padding: const EdgeInsets.all(8.0), + color: Colors.black54, child: Row( children: [ - // [추가] 이미지 전송 버튼 IconButton( icon: const Icon(Icons.add_photo_alternate, color: Colors.blueAccent), onPressed: _pickAndSendImage, @@ -145,10 +294,10 @@ class _GameChatOverlayState extends State { controller: _textController, style: const TextStyle(color: Colors.white), decoration: const InputDecoration( - hintText: "채팅 입력...", + hintText: "메시지 보내기...", hintStyle: TextStyle(color: Colors.white54), border: InputBorder.none, - isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 10), ), onSubmitted: _sendMessage, ), @@ -173,26 +322,19 @@ class _GameChatOverlayState extends State { _textController.clear(); } - // [이미지 선택 및 전송 로직] Future _pickAndSendImage() async { final picker = ImagePicker(); - // 갤러리에서 이미지 선택 (압축 옵션 추가 권장) final XFile? image = await picker.pickImage( source: ImageSource.gallery, - imageQuality: 50, // 전송 속도를 위해 품질 낮춤 - maxWidth: 800, + imageQuality: 70, + maxWidth: 1024, ); if (image != null) { - // MediaManager를 통해 전송 - await MediaManager().sendMedia( - filePath: image.path, - type: 'IMAGE', - ); + await MediaManager().sendMedia(filePath: image.path, type: 'IMAGE'); } } - // [이미지 크게 보기 팝업] void _showFullImage(BuildContext context, MediaItem item) { showDialog( context: context, @@ -202,23 +344,33 @@ class _GameChatOverlayState extends State { child: Stack( alignment: Alignment.center, children: [ - InteractiveViewer( - child: Image.file(File(item.filePath)), - ), + InteractiveViewer(child: Image.file(File(item.filePath))), + Positioned( - top: 40, - right: 20, - child: IconButton( - icon: const Icon(Icons.close, color: Colors.white, size: 30), - onPressed: () => Navigator.pop(ctx), + top: 40, left: 20, right: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.close, color: Colors.white, size: 30), + onPressed: () => Navigator.pop(ctx), + ), + IconButton( + icon: const Icon(Icons.download, color: Colors.white, size: 30), + tooltip: "갤러리에 저장", + onPressed: () => _saveImageToGallery(context, item.filePath), + ), + ], ), ), + Positioned( bottom: 20, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), color: Colors.black54, - child: Text("보낸 사람: ${item.senderName}", style: const TextStyle(color: Colors.white)), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(5)), + child: Text("From: ${item.senderName}", style: const TextStyle(color: Colors.white)), ), ) ], @@ -226,4 +378,21 @@ class _GameChatOverlayState extends State { ), ); } + + Future _saveImageToGallery(BuildContext context, String filePath) async { + try { + await Gal.putImage(filePath); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("갤러리에 저장되었습니다! ✅")), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("저장 실패: $e")), + ); + } + } + } } \ No newline at end of file diff --git a/packages/core/lib/widgets/voice_widget.dart b/packages/core/lib/widgets/voice_widget.dart new file mode 100644 index 0000000..68eb006 --- /dev/null +++ b/packages/core/lib/widgets/voice_widget.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import '../manager/voice_manager.dart'; + +class VoiceWidget extends StatefulWidget { + final bool isListening; + + const VoiceWidget({super.key, required this.isListening}); + + @override + State createState() => _VoiceWidgetState(); +} + +class _VoiceWidgetState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; + String _liveText = "말씀하세요..."; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000) + )..repeat(reverse: true); + + // 실시간 인식 내용 구독 + VoiceManager().resultStream.listen((text) { + if (mounted) { + setState(() => _liveText = text); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!widget.isListening) return const SizedBox(); + + return Align( + alignment: Alignment.bottomCenter, + child: Container( + margin: const EdgeInsets.only(bottom: 100), // 하단에서 좀 띄움 + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(30), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 마이크 아이콘 애니메이션 + FadeTransition( + opacity: _controller, + child: const Icon(Icons.mic, color: Colors.redAccent, size: 40), + ), + const SizedBox(height: 10), + // 인식된 텍스트 + Text( + _liveText, + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml index 43f00e6..21efc15 100644 --- a/packages/core/pubspec.yaml +++ b/packages/core/pubspec.yaml @@ -18,11 +18,13 @@ dependencies: sqlite3_flutter_libs: ^0.5.0 path_provider: ^2.1.1 path: ^1.8.3 - + gal: ^2.3.0 # [추가] 갤러리 저장용 # [파일 피커] - image_picker: ^1.0.4 - file_picker: ^6.1.1 - + image_picker: ^1.1.2 + file_picker: ^8.1.4 + shared_preferences: ^2.2.2 + speech_to_text: ^7.0.0 + flutter_local_notifications: ^17.0.0 dev_dependencies: drift_dev: ^2.13.0 build_runner: ^2.4.6 \ No newline at end of file diff --git a/packages/games/quiz/lib/quiz_game.dart b/packages/games/quiz/lib/quiz_game.dart index 4aee447..4b3b07e 100644 --- a/packages/games/quiz/lib/quiz_game.dart +++ b/packages/games/quiz/lib/quiz_game.dart @@ -12,32 +12,33 @@ class QuizGame extends BaseGame { String get name => "OX 퀴즈 서바이벌"; @override - String get description => "방장도 플레이어! 3초 안에 선택하세요."; + String get description => "끝까지 살아남으세요!"; // ------------------------------------------------------------------------ // 상태 변수 // ------------------------------------------------------------------------ final _gameStateController = StreamController>.broadcast(); Stream> get gameStateStream => _gameStateController.stream; - StreamSubscription? _networkSubscription; - + // UI 초기화 지연 방지용 데이터 Map? _lastState; - // 게임 데이터 - final Set _aliveUsers = {}; // 생존자 ID - final Set _answeredUsers = {}; // 답변 제출자 ID + final Set _aliveUsers = {}; + final Set _answeredUsers = {}; - // 나의 상태 PlayerStatus _myStatus = PlayerStatus.alive; String? _mySelectedAnswer; bool _isLockedIn = false; Timer? _lockInTimer; - // 카운트다운 상태 + // [상태] 카운트다운 중인가? bool _isCountingDown = false; int _countdownValue = 3; + // [상태] 정답 공개 중인가? (중간 대기 화면) + bool _isShowingResult = false; + String _currentCorrectAnswer = ""; + final List> _questions = [ {"q": "사과는 영어로 Apple이다.", "a": "O"}, {"q": "바나나는 길어지면 기차다.", "a": "X"}, @@ -54,25 +55,23 @@ class QuizGame extends BaseGame { // ------------------------------------------------------------------------ @override void onStart() { + super.onStart(); print("Quiz Game Started!"); + _resetLocalState(); _lastState = null; _aliveUsers.clear(); - // 참가자 명단 초기화 (나 + 게스트) _aliveUsers.add(NetworkManager().me.id); for (var guest in NetworkManager().guestList) { _aliveUsers.add(guest.id); } - _networkSubscription = NetworkManager().messageStream.listen((payload) { - onMessageReceived("", payload); - }); - - // [Host] 잠시 후 카운트다운 시작 + // [Host] 게임 시작 시퀀스 진입 if (NetworkManager().role == NetworkRole.host) { - Future.delayed(const Duration(milliseconds: 1000), () { - _startCountdown(); + // 잠시 대기 후 첫 번째 문제 카운트다운 시작 + Future.delayed(const Duration(seconds: 1), () { + _startNextQuestionSequence(); }); } } @@ -80,24 +79,8 @@ class QuizGame extends BaseGame { @override void onDispose() { _lockInTimer?.cancel(); - _networkSubscription?.cancel(); _gameStateController.close(); - } - - // [Host] 카운트다운 - void _startCountdown() { - if (_currentQuestionIndex != -1) return; - - Timer.periodic(const Duration(seconds: 1), (timer) { - int nextCount = 3 - timer.tick; - if (nextCount > 0) { - _broadcastState({'type': 'GAME_COUNTDOWN', 'count': nextCount}); - } else { - timer.cancel(); - _nextQuestion(); // 첫 문제 출제 - } - }); - _broadcastState({'type': 'GAME_COUNTDOWN', 'count': 3}); + super.onDispose(); } // ------------------------------------------------------------------------ @@ -110,11 +93,15 @@ class QuizGame extends BaseGame { _lastState = payload; } - // 1. [Common] 카운트다운 + // 1. [Common] 카운트다운 수신 if (payload['type'] == 'GAME_COUNTDOWN') { + _isShowingResult = false; // 결과 화면 끄기 _isCountingDown = true; _countdownValue = payload['count']; + + // 3, 2, 1 소리 SoundManager().playSfx(SoundKey.click); + _gameStateController.add(payload); } @@ -130,12 +117,11 @@ class QuizGame extends BaseGame { _answeredUsers.add(userId); - // 정답 체크 (결과는 바로 반영하되, 탈락 통보는 결과 화면 때 보냄) final currentAnswer = _questions[_currentQuestionIndex]['a']; bool isCorrect = (answer == currentAnswer); if (!isCorrect) { - _aliveUsers.remove(userId); // 명단에서는 제거하되, 통보는 나중에 + _aliveUsers.remove(userId); } // 제출 현황 전파 @@ -143,37 +129,33 @@ class QuizGame extends BaseGame { 'type': 'PLAYER_STATUS_UPDATE', 'userId': userId, 'isSubmitted': true, - 'isAlive': true // 아직은 살아있는 척 (결과 화면에서 공개) + 'isAlive': isCorrect }); - // [핵심 변경] 전원 제출 완료 시 -> 결과 발표 화면으로 이동 - // (방금 죽은 사람 포함해서 이번 라운드 시작 인원만큼 답변이 왔는지 체크) - int currentRoundPlayers = _aliveUsers.length + (isCorrect ? 0 : 1); // 방금 뺀 사람 포함 - // 더 정확히는: answeredUsers가 이번 라운드 참가자 수에 도달하면 진행 - // (여기선 간단히 answeredUsers가 더이상 늘어날 수 없을 때로 판단) - - // 타임아웃 로직이 없으므로, 현재 살아있는 사람들이 다 냈으면 진행 - // (로직이 복잡해질 수 있으므로, 간단히 '살아있는 사람 수 == 답변 수'가 아니라 - // '이번 라운드 시작 시점의 생존자 수'를 별도 변수로 관리하는 게 정석이지만, - // 여기서는 생존자 수 + 이번에 틀린 사람 수로 계산) - - // 간단 로직: 1초 뒤 체크해서 더 낼 사람이 없으면 진행 (혹은 모두 냈으면 바로) - Future.delayed(const Duration(milliseconds: 500), () { - // 대충 모두 냈다고 판단되면 (추가 보정 필요할 수 있음) - if (_answeredUsers.length >= (_aliveUsers.length + (isCorrect?0:1))) { - _showRoundResult(); - } - }); + // [자동 진행] 전원 제출 시 -> 결과 발표 -> 카운트다운 -> 다음 문제 + int currentAliveCount = _aliveUsers.length + (isCorrect ? 0 : 1); + if (_answeredUsers.length >= currentAliveCount) { + Future.delayed(const Duration(milliseconds: 500), () { + _showRoundResultAndNext(); // 결과 발표 및 다음 단계 + }); + } } - // 3. [Common] 중간 결과 발표 (NEW) + // 3. [Common] 중간 결과 발표 (정답 공개) if (payload['type'] == 'ROUND_RESULT') { _isCountingDown = false; - final bool isSurvived = payload['survivors'].contains(NetworkManager().me.id); + _isShowingResult = true; // 결과 화면 모드 진입 + _currentCorrectAnswer = payload['correctAnswer']; - // 내 생존 여부 업데이트 + final List survivors = payload['survivors'] ?? []; + final bool isSurvived = survivors.contains(NetworkManager().me.id); + + // 내 생존 여부 업데이트 및 효과음 if (!isSurvived && _myStatus == PlayerStatus.alive) { _handleElimination(); + } else if (isSurvived && _myStatus == PlayerStatus.alive) { + // 정답 소리 (선택 사항) + // SoundManager().playSfx(SoundKey.correct); } _gameStateController.add(payload); @@ -183,17 +165,31 @@ class QuizGame extends BaseGame { if (payload['type'] == 'PLAYER_STATUS_UPDATE') { final userId = payload['userId']; _answeredUsers.add(userId); + if (payload['isAlive'] == false) { + _aliveUsers.remove(userId); + } _gameStateController.add(payload); } - // 5. [Common] 새 문제 시작 + // 5. [Common] 탈락 통보 (본인) + if (payload['type'] == 'PLAYER_ELIMINATED') { + final targetId = payload['targetUserId']; + _aliveUsers.remove(targetId); + if (targetId == NetworkManager().me.id) { + _handleElimination(); + } + _gameStateController.add({'type': 'UI_REFRESH'}); + } + + // 6. [Common] 새 문제 시작 if (payload['type'] == 'GAME_STATE_UPDATE' && payload['status'] == 'QUESTION') { _isCountingDown = false; + _isShowingResult = false; _resetLocalState(); _gameStateController.add(payload); } - // 6. [Common] 종료 + // 7. [Common] 종료 if (payload['type'] == 'GAME_OVER' || payload['type'] == 'GAME_EXIT') { if (payload['type'] == 'GAME_OVER') { final winnerId = payload['winnerId']; @@ -210,30 +206,32 @@ class QuizGame extends BaseGame { } // ------------------------------------------------------------------------ - // [Host Logic] + // [Host Logic] 진행 관리자 // ------------------------------------------------------------------------ - // [NEW] 결과 발표 단계 - void _showRoundResult() { + + // 1. 라운드 결과 발표 (정답 O/X 보여주기) + void _showRoundResultAndNext() { final currentQ = _questions[_currentQuestionIndex]; final resultData = { 'type': 'ROUND_RESULT', 'status': 'RESULT', 'correctAnswer': currentQ['a'], - 'survivors': _aliveUsers.toList(), // 생존자 명단 전송 + 'survivors': _aliveUsers.toList(), }; - _broadcastState(resultData); - // 3초 뒤 다음 문제로 자동 이동 + // 3초간 결과 보여주고 -> 카운트다운 시작 Future.delayed(const Duration(seconds: 3), () { - _nextQuestion(); + _checkWinnerAndNext(); }); } - void _nextQuestion() { - // 승패 판정 + // 2. 승패 체크 후 -> 카운트다운 -> 문제 출제 + void _checkWinnerAndNext() { int totalStartPlayers = NetworkManager().guestList.length + 1; + + // 종료 조건 if ((totalStartPlayers > 1 && _aliveUsers.length <= 1) || _currentQuestionIndex >= _questions.length - 1) { String? winnerId; if (_aliveUsers.isNotEmpty) winnerId = _aliveUsers.first; @@ -241,6 +239,27 @@ class QuizGame extends BaseGame { return; } + // 다음 문제 준비 시퀀스 시작 + _startNextQuestionSequence(); + } + + // 3. 카운트다운 (3->2->1) 후 문제 전송 + void _startNextQuestionSequence() { + int count = 3; + // 1초 간격 타이머 + Timer.periodic(const Duration(seconds: 1), (timer) { + _broadcastState({'type': 'GAME_COUNTDOWN', 'count': count}); + + if (count == 0) { + timer.cancel(); + _sendNewQuestion(); // 문제 전송 + } + count--; + }); + } + + // 4. 실제 문제 데이터 전송 + void _sendNewQuestion() { _currentQuestionIndex++; final questionData = _questions[_currentQuestionIndex]; @@ -285,21 +304,22 @@ class QuizGame extends BaseGame { } // ------------------------------------------------------------------------ - // [UI] Unified View + // [UI] Unified View (통일된 UI) // ------------------------------------------------------------------------ @override - Widget buildHostView(BuildContext context) => _buildGameScreen(context, isHost: true); + Widget buildHostView(BuildContext context) => _buildSharedScreen(context, isHost: true); @override - Widget buildGuestView(BuildContext context) => _buildGameScreen(context, isHost: false); + Widget buildGuestView(BuildContext context) => _buildSharedScreen(context, isHost: false); - Widget _buildGameScreen(BuildContext context, {required bool isHost}) { + Widget _buildSharedScreen(BuildContext context, {required bool isHost}) { return Scaffold( appBar: AppBar( - title: const Text("OX 서바이벌"), + title: const Text("OX 서바이벌", style: TextStyle(fontWeight: FontWeight.bold)), + centerTitle: true, // 타이틀 중앙 정렬 통일 automaticallyImplyLeading: false, actions: [ - if (isHost) IconButton(icon: const Icon(Icons.power_settings_new), onPressed: () => _confirmExit(context)) + if (isHost) IconButton(icon: const Icon(Icons.close), onPressed: () => _confirmExit(context)) ], ), body: StreamBuilder>( @@ -310,79 +330,56 @@ class QuizGame extends BaseGame { final data = snapshot.data!; - if (data['type'] == 'GAME_COUNTDOWN') { - int count = data['count'] ?? 3; - return Center(child: Text("$count", style: const TextStyle(fontSize: 120, fontWeight: FontWeight.bold, color: Colors.blueAccent))); - } - + // 1. 종료 화면 + if (data['type'] == 'GAME_OVER') return _buildResultScreen(context, data['winnerName']); if (data['type'] == 'GAME_EXIT') { WidgetsBinding.instance.addPostFrameCallback((_) { if(context.mounted) Navigator.pop(context); }); - return const Center(child: Text("종료 중...")); - } - if (data['type'] == 'GAME_OVER') { - return _buildResultScreen(context, data['winnerName']); + return const Center(child: Text("종료되었습니다.")); } - // [NEW] 중간 결과 화면 - if (data['status'] == 'RESULT') { + // 2. 카운트다운 화면 (문제 직전) + // count가 0일 때는 문제 화면으로 넘어가기 직전이므로 잠깐 보여도 됨 + if (_isCountingDown) { + int count = data['count'] ?? 3; + // 0초는 'Start!' 등으로 표현하거나 생략 가능 + String text = count > 0 ? "$count" : "GO!"; + return Center( + child: Text( + text, + style: TextStyle(fontSize: 120, fontWeight: FontWeight.bold, color: Theme.of(context).primaryColor) + ) + ); + } + + // 3. 중간 결과 화면 (정답 공개) + if (_isShowingResult || data['status'] == 'RESULT') { return _buildRoundResultScreen(data); } - // 문제 풀이 화면 + // 4. 문제 풀이 화면 if (data['status'] == 'QUESTION' || _currentQuestionIndex >= 0) { Map qData = data['data'] ?? _questions[_currentQuestionIndex]; + // 인원 수 계산 int answered = data['answeredCount'] ?? _answeredUsers.length; - // 총인원 계산 (생존자 기준이 아님, 이번 라운드 참여자 기준이어야 함. 여기선 간단히 전체 인원 사용) - int total = NetworkManager().guestList.length + 1; + int total = isHost ? _aliveUsers.length : (data['totalAlive'] ?? _aliveUsers.length); + if (total == 0) total = 1; // div by zero 방지 return _buildPlayArea(context, qData, answered, total); } - return _buildWaitingScreen("대기 중..."); + return _buildWaitingScreen("잠시만 기다려주세요..."); }, ), ); } - // [NEW] 중간 결과 화면 위젯 - Widget _buildRoundResultScreen(Map data) { - final String correctAnswer = data['correctAnswer']; - final List survivors = data['survivors'] ?? []; - final bool amISurvived = survivors.contains(NetworkManager().me.id); - - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text("정답은?", style: TextStyle(fontSize: 24, color: Colors.grey)), - const SizedBox(height: 20), - // 정답 표시 - Container( - width: 150, height: 150, - decoration: BoxDecoration( - color: correctAnswer == "O" ? Colors.blue : Colors.red, - shape: BoxShape.circle, - ), - child: Center(child: Text(correctAnswer, style: const TextStyle(fontSize: 80, color: Colors.white, fontWeight: FontWeight.bold))), - ), - const SizedBox(height: 40), - - // 생존 여부 메시지 - if (_myStatus == PlayerStatus.dead) - const Text("이미 탈락하셨습니다. 👻", style: TextStyle(fontSize: 20, color: Colors.grey)) - else if (amISurvived) - const Text("생존! 다음 라운드 진출! 🎉", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green)) - else - const Text("탈락했습니다... 😭", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.red)), - - const SizedBox(height: 20), - Text("잠시 후 다음 문제가 시작됩니다.", style: TextStyle(color: Colors.grey[600])), - ], - ), - ); - } - + // ------------------------------------------------------------------------ + // UI Components + // ------------------------------------------------------------------------ + + // [문제 풀이 화면] Widget _buildPlayArea(BuildContext context, Map qData, int answered, int total) { + // 탈락자 뷰 if (_myStatus == PlayerStatus.dead) { return Center( child: Column( @@ -392,20 +389,33 @@ class QuizGame extends BaseGame { const SizedBox(height: 20), const Text("탈락했습니다 👻", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), const SizedBox(height: 20), - Text("관전 중... ($answered명 제출)", style: const TextStyle(fontSize: 18, color: Colors.grey)), + Text("관전 중... ($answered / $total 제출)", style: const TextStyle(fontSize: 18, color: Colors.grey)), const SizedBox(height: 40), - Text("문제: ${qData['q']}", style: const TextStyle(color: Colors.grey)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 30), + child: Text("문제: ${qData['q']}", textAlign: TextAlign.center, style: const TextStyle(color: Colors.grey)), + ), ], ), ); } + // 생존자 뷰 return Column( children: [ // 상단 현황판 _PlayerStatusGrid(aliveUsers: _aliveUsers, answeredUsers: _answeredUsers), - const Divider(), + const Divider(height: 1), + // 진행바 + LinearProgressIndicator( + value: total > 0 ? answered / total : 0, + minHeight: 6, + backgroundColor: Colors.grey[200], + valueColor: const AlwaysStoppedAnimation(Colors.orange), + ), + + // 문제 텍스트 Expanded( flex: 4, child: Center( @@ -419,6 +429,8 @@ class QuizGame extends BaseGame { ), ), ), + + // 컨트롤 (버튼) Expanded( flex: 3, child: _isLockedIn @@ -431,11 +443,13 @@ class QuizGame extends BaseGame { ], ), ), + + // 하단 안내 SizedBox( height: 60, child: Center( child: _mySelectedAnswer != null && !_isLockedIn - ? const Text("3초 후 확정됩니다! (변경 가능)", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)) + ? const Text("3초 후 확정됩니다!", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)) : const SizedBox(), ), ), @@ -443,8 +457,53 @@ class QuizGame extends BaseGame { ); } - // ... (이하 _buildLockedUI, _selectAnswer 등 기존 함수들 유지) ... - + // [결과 발표 화면] + Widget _buildRoundResultScreen(Map data) { + final String correctAnswer = data['correctAnswer'] ?? "?"; + final List survivors = data['survivors'] ?? []; + final bool amISurvived = survivors.contains(NetworkManager().me.id); + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("정답은?", style: TextStyle(fontSize: 24, color: Colors.grey)), + const SizedBox(height: 20), + + // 정답 O/X 표시 + Container( + width: 160, height: 160, + decoration: BoxDecoration( + color: correctAnswer == "O" ? Colors.blue : Colors.red, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: const Offset(0, 5))] + ), + child: Center( + child: Text( + correctAnswer, + style: const TextStyle(fontSize: 100, color: Colors.white, fontWeight: FontWeight.bold) + ) + ), + ), + const SizedBox(height: 40), + + // 상태 메시지 + if (_myStatus == PlayerStatus.dead) + const Text("이미 탈락하셨습니다. 👻", style: TextStyle(fontSize: 20, color: Colors.grey)) + else if (amISurvived) + const Text("생존! 🎉", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.green)) + else + const Text("탈락했습니다... 😭", style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.red)), + + const SizedBox(height: 20), + // 방장에게만 보이는 비상 버튼 (혹시 멈출까봐) + if (NetworkManager().role == NetworkRole.host) + TextButton(onPressed: () => _checkWinnerAndNext(), child: const Text("강제 진행 (비상용)", style: TextStyle(color: Colors.grey))) + ], + ), + ); + } + Widget _buildLockedUI() { return Center( child: Column( @@ -456,163 +515,59 @@ class QuizGame extends BaseGame { color: _mySelectedAnswer == "O" ? Colors.blue : Colors.red, ), const SizedBox(height: 20), - const Text("제출 완료! 결과를 기다리는 중...", style: TextStyle(fontSize: 20, color: Colors.grey)), + const Text("제출 완료!\n결과를 기다리는 중...", textAlign: TextAlign.center, style: TextStyle(fontSize: 18, color: Colors.grey)), ], ), ); } + // ... (이하 _selectAnswer, _submitFinalAnswer, _buildResultScreen, _buildWaitingScreen, _confirmExit 동일) ... void _selectAnswer(String answer) { _lockInTimer?.cancel(); _mySelectedAnswer = answer; SoundManager().playSfx(SoundKey.click); - _updateLocalState({'type': 'UI_REFRESH'}); - - _lockInTimer = Timer(const Duration(seconds: 3), () { - _submitFinalAnswer(); - }); + _gameStateController.add({'type': 'UI_REFRESH'}); + _lockInTimer = Timer(const Duration(seconds: 3), () { _submitFinalAnswer(); }); } - + void _submitFinalAnswer() { if (_mySelectedAnswer == null) return; _isLockedIn = true; - _updateLocalState({'type': 'UI_REFRESH'}); - - final payload = { - 'type': 'ANSWER_SUBMIT', - 'answer': _mySelectedAnswer, - 'userId': NetworkManager().me.id, - }; - - if (NetworkManager().role == NetworkRole.host) { - onMessageReceived("", payload); - } else { - NetworkManager().sendMessage(payload); - } + _gameStateController.add({'type': 'UI_REFRESH'}); + final payload = {'type': 'ANSWER_SUBMIT', 'answer': _mySelectedAnswer, 'userId': NetworkManager().me.id}; + if (NetworkManager().role == NetworkRole.host) { onMessageReceived("", payload); } else { NetworkManager().sendMessage(payload); } } - void _updateLocalState(Map data) { - _gameStateController.add(data); - } - Widget _buildResultScreen(BuildContext context, String winnerName) { bool amIWinner = _myStatus == PlayerStatus.winner; - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(amIWinner ? Icons.emoji_events : Icons.thumb_down, size: 100, color: amIWinner ? Colors.amber : Colors.grey), - const SizedBox(height: 20), - Text(amIWinner ? "우승!" : "게임 종료", style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold)), - const SizedBox(height: 10), - Text("최종 우승자: $winnerName", style: const TextStyle(fontSize: 20)), - const SizedBox(height: 50), - ElevatedButton(onPressed: () { onDispose(); Navigator.pop(context); }, child: const Text("나가기")), - ], - ), - ); + return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(amIWinner ? Icons.emoji_events : Icons.thumb_down, size: 100, color: amIWinner ? Colors.amber : Colors.grey), const SizedBox(height: 20), Text(amIWinner ? "우승!" : "게임 종료", style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold)), const SizedBox(height: 10), Text("최종 우승자: $winnerName", style: const TextStyle(fontSize: 20)), const SizedBox(height: 50), ElevatedButton(onPressed: () { onDispose(); Navigator.pop(context); }, child: const Text("로비로 돌아가기"))])); } - + Widget _buildWaitingScreen(String msg) => Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [const CircularProgressIndicator(), SizedBox(height: 20), Text(msg)])); - + void _confirmExit(BuildContext context) { - showDialog(context: context, builder: (ctx) => AlertDialog( - title: const Text("게임 종료"), content: const Text("방을 폭파하시겠습니까?"), - actions: [ - TextButton(onPressed: ()=>Navigator.pop(ctx), child: const Text("취소")), - TextButton(onPressed: () { - Navigator.pop(ctx); - NetworkManager().sendMessage({'type': 'GAME_EXIT'}); - onDispose(); - Navigator.pop(context); - }, child: const Text("종료", style: TextStyle(color: Colors.red))), - ] - )); + showDialog(context: context, builder: (ctx) => AlertDialog(title: const Text("게임 종료"), content: const Text("방을 폭파하시겠습니까?"), actions: [TextButton(onPressed: ()=>Navigator.pop(ctx), child: const Text("취소")), TextButton(onPressed: () { Navigator.pop(ctx); NetworkManager().sendMessage({'type': 'GAME_EXIT'}); onDispose(); Navigator.pop(context); }, child: const Text("종료", style: TextStyle(color: Colors.red)))])); } } -// ------------------------------------------------------------------------ -// [Widget] 현황판 (기존 코드와 동일하지만 함께 제공) -// ------------------------------------------------------------------------ +// [현황판] class _PlayerStatusGrid extends StatelessWidget { final Set aliveUsers; final Set answeredUsers; - const _PlayerStatusGrid({required this.aliveUsers, required this.answeredUsers}); - @override Widget build(BuildContext context) { final allUsers = [NetworkManager().me, ...NetworkManager().guestList]; - - return Container( - height: 90, - width: double.infinity, - padding: const EdgeInsets.all(10), - color: Colors.grey[100], - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: allUsers.length, - itemBuilder: (context, index) { - final user = allUsers[index]; - final isAlive = aliveUsers.contains(user.id); - final isSubmitted = answeredUsers.contains(user.id); - final isMe = user.id == NetworkManager().me.id; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Column( - children: [ - Stack( - children: [ - Container( - width: 50, height: 50, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isAlive ? Color(user.colorValue) : Colors.grey, - border: isSubmitted ? Border.all(color: Colors.green, width: 3) : null, - ), - child: Center( - child: isAlive - ? Text(user.nickname[0], style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)) - : const Icon(Icons.close, color: Colors.white), - ), - ), - if (isMe) Positioned(top:0, right:0, child: Container(width: 10, height: 10, decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle))), - ], - ), - const SizedBox(height: 4), - Text(user.nickname, style: TextStyle(fontSize: 10, color: isAlive ? Colors.black : Colors.grey)), - ], - ), - ); - }, - ), - ); + return Container(height: 80, width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 10), color: Colors.grey[50], child: ListView.builder(scrollDirection: Axis.horizontal, itemCount: allUsers.length, itemBuilder: (context, index) { final user = allUsers[index]; final isAlive = aliveUsers.contains(user.id); final isSubmitted = answeredUsers.contains(user.id); return Padding(padding: const EdgeInsets.symmetric(horizontal: 6.0), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Stack(children: [Container(width: 40, height: 40, decoration: BoxDecoration(shape: BoxShape.circle, color: isAlive ? Color(user.colorValue) : Colors.grey, border: isSubmitted ? Border.all(color: Colors.green, width: 3) : null), child: AvatarWidget(user: user, size: 40)), if (!isAlive) Positioned.fill(child: Container(decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle), child: const Icon(Icons.close, size: 20, color: Colors.white)))]), const SizedBox(height: 4), Text(user.nickname, style: TextStyle(fontSize: 10, color: isAlive ? Colors.black : Colors.grey))])); })); } } +// [버튼] class _AnswerBtn extends StatelessWidget { - final String text; - final Color color; - final bool isSelected; - final VoidCallback onTap; + final String text; final Color color; final bool isSelected; final VoidCallback onTap; const _AnswerBtn({required this.text, required this.color, required this.isSelected, required this.onTap}); @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: isSelected ? 140 : 120, - height: isSelected ? 140 : 120, - decoration: BoxDecoration( - color: color.withOpacity(isSelected ? 1.0 : 0.6), - shape: BoxShape.circle, - border: isSelected ? Border.all(color: Colors.white, width: 5) : null, - boxShadow: [BoxShadow(color: color.withOpacity(0.4), blurRadius: 10, offset: const Offset(0, 6))] - ), - child: Center(child: Text(text, style: const TextStyle(fontSize: 60, color: Colors.white, fontWeight: FontWeight.bold))), - ), - ); + return GestureDetector(onTap: onTap, child: AnimatedContainer(duration: const Duration(milliseconds: 200), width: isSelected ? 140 : 120, height: isSelected ? 140 : 120, decoration: BoxDecoration(color: color.withOpacity(isSelected ? 1.0 : 0.6), shape: BoxShape.circle, border: isSelected ? Border.all(color: Colors.white, width: 5) : null, boxShadow: [BoxShadow(color: color.withOpacity(0.4), blurRadius: 10, offset: const Offset(0, 6))]), child: Center(child: Text(text, style: const TextStyle(fontSize: 60, color: Colors.white, fontWeight: FontWeight.bold))))); } } \ No newline at end of file