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