...
This commit is contained in:
parent
92a4525091
commit
bc57468aaa
31
.dart_tool/extension_discovery/README.md
Normal file
31
.dart_tool/extension_discovery/README.md
Normal file
@ -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.
|
||||||
1
.dart_tool/extension_discovery/vs_code.json
Normal file
1
.dart_tool/extension_discovery/vs_code.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"version":2,"entries":[{"package":"playWith","rootUri":"../","packageUri":"lib/"}]}
|
||||||
@ -12,6 +12,7 @@ android {
|
|||||||
|
|
||||||
// [수정할 부분]
|
// [수정할 부분]
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
// 기존 1.8 -> 17로 변경
|
// 기존 1.8 -> 17로 변경
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
@ -46,6 +47,12 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// [추가] 디슈가링 라이브러리 추가 (필수)
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,17 @@
|
|||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.speech.RecognitionService" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="playwith_app"
|
android:label="playwith_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@ -5,6 +5,9 @@ PODS:
|
|||||||
- bonsoir_darwin (0.0.1):
|
- bonsoir_darwin (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- CwlCatchException (2.2.1):
|
||||||
|
- CwlCatchExceptionSupport (~> 2.2.1)
|
||||||
|
- CwlCatchExceptionSupport (2.2.1)
|
||||||
- DKImagePickerController/Core (4.3.9):
|
- DKImagePickerController/Core (4.3.9):
|
||||||
- DKImagePickerController/ImageDataManager
|
- DKImagePickerController/ImageDataManager
|
||||||
- DKImagePickerController/Resource
|
- DKImagePickerController/Resource
|
||||||
@ -40,6 +43,11 @@ PODS:
|
|||||||
- DKImagePickerController/PhotoGallery
|
- DKImagePickerController/PhotoGallery
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
|
- flutter_local_notifications (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- gal (1.0.0):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- GoogleDataTransport (9.4.1):
|
- GoogleDataTransport (9.4.1):
|
||||||
- GoogleUtilities/Environment (~> 7.7)
|
- GoogleUtilities/Environment (~> 7.7)
|
||||||
- nanopb (< 2.30911.0, >= 2.30908.0)
|
- nanopb (< 2.30911.0, >= 2.30908.0)
|
||||||
@ -102,6 +110,13 @@ PODS:
|
|||||||
- SDWebImage (5.21.1):
|
- SDWebImage (5.21.1):
|
||||||
- SDWebImage/Core (= 5.21.1)
|
- SDWebImage/Core (= 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 (3.50.4):
|
||||||
- sqlite3/common (= 3.50.4)
|
- sqlite3/common (= 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`)
|
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- 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`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
|
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
|
||||||
- objective_c (from `.symlinks/plugins/objective_c/ios`)
|
- objective_c (from `.symlinks/plugins/objective_c/ios`)
|
||||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/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`)
|
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
|
- CwlCatchException
|
||||||
|
- CwlCatchExceptionSupport
|
||||||
- DKImagePickerController
|
- DKImagePickerController
|
||||||
- DKPhotoGallery
|
- DKPhotoGallery
|
||||||
- GoogleDataTransport
|
- GoogleDataTransport
|
||||||
@ -169,6 +190,10 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/file_picker/ios"
|
:path: ".symlinks/plugins/file_picker/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
|
flutter_local_notifications:
|
||||||
|
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||||
|
gal:
|
||||||
|
:path: ".symlinks/plugins/gal/darwin"
|
||||||
image_picker_ios:
|
image_picker_ios:
|
||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
mobile_scanner:
|
mobile_scanner:
|
||||||
@ -177,16 +202,24 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/objective_c/ios"
|
:path: ".symlinks/plugins/objective_c/ios"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
: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:
|
sqlite3_flutter_libs:
|
||||||
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
|
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
audioplayers_darwin: 4027b33a8f471d996c13f71cb77f0b1583b5d923
|
audioplayers_darwin: 4027b33a8f471d996c13f71cb77f0b1583b5d923
|
||||||
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
|
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
|
||||||
|
CwlCatchException: 7acc161b299a6de7f0a46a6ed741eae2c8b4d75a
|
||||||
|
CwlCatchExceptionSupport: 54ccab8d8c78907b57f99717fb19d4cc3bce02dc
|
||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||||
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
|
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
|
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
|
||||||
|
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
|
||||||
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
|
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
|
||||||
GoogleMLKit: 97ac7af399057e99182ee8edfa8249e3226a4065
|
GoogleMLKit: 97ac7af399057e99182ee8edfa8249e3226a4065
|
||||||
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
|
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
|
||||||
@ -204,6 +237,8 @@ SPEC CHECKSUMS:
|
|||||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||||
|
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||||
|
speech_to_text: 87bf9298952e8d9073be1b6aade6d5758db5170c
|
||||||
sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b
|
sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b
|
||||||
sqlite3_flutter_libs: 86f82662868ee26ff3451f73cac9c5fc2a1f57fa
|
sqlite3_flutter_libs: 86f82662868ee26ff3451f73cac9c5fc2a1f57fa
|
||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
|
|||||||
@ -56,12 +56,16 @@
|
|||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>QR 코드를 스캔하여 방에 접속하기 위해 카메라 권한이 필요합니다.</string>
|
<string>QR 코드를 스캔하여 방에 접속하기 위해 카메라 권한이 필요합니다.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>채팅방에 사진을 공유하기 위해 갤러리 접근 권한이 필요합니다.</string>
|
<string>채팅방에 사진을 공유하기 위해 갤러리 접근 권한이 필요합니다.</string>
|
||||||
|
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>사진을 찍어 공유하기 위해 카메라 권한이 필요합니다.</string>
|
<string>사진을 찍어 공유하기 위해 카메라 권한이 필요합니다.</string>
|
||||||
|
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>동영상 촬영을 위해 마이크 권한이 필요합니다.</string>
|
<string>동영상 촬영을 위해 마이크 권한이 필요합니다.</string>
|
||||||
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
|
<string>이미지를 갤러리에 저장하기 위해 권한이 필요합니다.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>정답을 음성으로 말하기 위해 마이크 권한이 필요합니다.</string>
|
||||||
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
|
<string>말한 내용을 텍스트로 변환하여 정답을 확인합니다.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
44
apps/app/lib/intro/intro_screen.dart
Normal file
44
apps/app/lib/intro/intro_screen.dart
Normal file
@ -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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
194
apps/app/lib/intro/intro_view.dart
Normal file
194
apps/app/lib/intro/intro_view.dart
Normal file
@ -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<IntroViewFlutter> createState() => _IntroViewFlutterState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IntroViewFlutterState extends State<IntroViewFlutter>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
late final Animation<int> _logoTextAnimation;
|
||||||
|
late final Animation<int> _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<TextSpan> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<IntroScreen> createState() => _IntroScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _IntroScreenState extends State<IntroScreen> {
|
|
||||||
final _nicknameController = TextEditingController();
|
|
||||||
|
|
||||||
Future<void> _enterLobby() async {
|
|
||||||
if (_nicknameController.text.trim().isEmpty) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text("닉네임을 입력해주세요.")),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// [수정됨] 플랫폼별 권한 분기 처리
|
|
||||||
if (Platform.isAndroid) {
|
|
||||||
// 🤖 안드로이드: 명시적 권한 요청 필요
|
|
||||||
Map<Permission, PermissionStatus> 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('입장하기'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
167
apps/app/lib/login_screen.dart
Normal file
167
apps/app/lib/login_screen.dart
Normal file
@ -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<LoginScreen> createState() => _LoginScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
|
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<void> _enterLobby() async {
|
||||||
|
final inputNick = _nicknameController.text.trim();
|
||||||
|
if (inputNick.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("닉네임을 입력해주세요.")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 안드로이드 권한 체크
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
Map<Permission, PermissionStatus> 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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,21 +1,23 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:playwith_core/playwith_core.dart';
|
import 'package:playwith_core/playwith_core.dart';
|
||||||
import 'package:playwith_game_quiz/quiz_game.dart'; // 퀴즈 모듈 import
|
import 'package:playwith_game_quiz/quiz_game.dart';
|
||||||
import 'intro_screen.dart';
|
import 'login_screen.dart'; // [수정] 인트로 스크린 import (경로가 다르면 수정 필요)
|
||||||
|
import 'intro/intro_screen.dart'; // 만약 intro 폴더에 넣으셨다면 이 경로 사용
|
||||||
|
import 'lobby_screen.dart';
|
||||||
|
|
||||||
void main() {
|
Future<void> main() async {
|
||||||
// 1. 플러터 바인딩 초기화
|
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
// 2. 사운드 리소스 주입 (여기가 핵심!)
|
|
||||||
// AssetSource는 'assets/' 접두어를 자동으로 붙이므로 그 하위 경로만 적습니다.
|
|
||||||
SoundManager().initialize(soundPaths: {
|
SoundManager().initialize(soundPaths: {
|
||||||
SoundKey.bgm: 'audio/bgm.mp3',
|
SoundKey.bgm: 'audio/bgm.mp3',
|
||||||
SoundKey.correct: 'audio/correct.mp3',
|
SoundKey.correct: 'audio/correct.mp3',
|
||||||
SoundKey.wrong: 'audio/wrong.mp3',
|
SoundKey.wrong: 'audio/wrong.mp3',
|
||||||
SoundKey.win: 'audio/win.mp3',
|
SoundKey.win: 'audio/win.mp3',
|
||||||
|
SoundKey.click: 'audio/correct.mp3',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await NotificationManager().initialize();
|
||||||
|
|
||||||
runApp(const PlayWithApp());
|
runApp(const PlayWithApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,43 +30,53 @@ class PlayWithApp extends StatefulWidget {
|
|||||||
|
|
||||||
class _PlayWithAppState extends State<PlayWithApp> {
|
class _PlayWithAppState extends State<PlayWithApp> {
|
||||||
final _net = NetworkManager();
|
final _net = NetworkManager();
|
||||||
|
final _settings = SettingsNotifier();
|
||||||
|
|
||||||
// 등록된 게임 목록
|
|
||||||
final List<BaseGame> _games = [
|
final List<BaseGame> _games = [
|
||||||
QuizGame(), // 여기서 등록!
|
QuizGame(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
// [전역 라우팅] 네트워크 메시지를 감시하다가 'GAME_START'가 오면 해당 게임 실행
|
|
||||||
_net.messageStream.listen((data) {
|
_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<NavigatorState>를 쓰거나, 현재 context를 찾아야 함.
|
|
||||||
// MVP에서는 LobbyScreen 내부에서 처리하는 것이 안전함.
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return ListenableBuilder(
|
||||||
title: 'PlayWith',
|
listenable: _settings,
|
||||||
theme: ThemeData(
|
builder: (context, child) {
|
||||||
primarySwatch: Colors.blue,
|
return MaterialApp(
|
||||||
useMaterial3: true,
|
title: 'PlayWith',
|
||||||
),
|
theme: _settings.currentTheme,
|
||||||
home: const IntroScreen(),
|
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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [팁] 인트로와 닉네임 입력(IntroScreen.dart의 기존 로직)을 연결하기 위한 래퍼
|
||||||
|
// 기존에 있던 닉네임 입력 화면(IntroScreen)과 이름이 겹치므로,
|
||||||
|
// 기존의 닉네임 입력 화면은 'LoginScreen'이나 'NameInputScreen'으로 이름을 바꾸는 게 좋습니다.
|
||||||
|
// 만약 'IntroScreen' 파일이 닉네임 입력 화면이었다면,
|
||||||
|
// 이번에 만든 애니메이션 화면을 'SplashAnimationScreen' 등으로 이름을 지어서 구분해주세요.
|
||||||
|
|
||||||
|
// 여기서는 이번에 만든 애니메이션 화면을 'IntroAnimationScreen'이라고 가정하고,
|
||||||
|
// 애니메이션이 끝나면 -> 닉네임 입력 화면(기존 IntroScreen) -> 로비 순서로 가는 게 자연스럽습니다.
|
||||||
187
apps/app/lib/screens/settings_screen.dart
Normal file
187
apps/app/lib/screens/settings_screen.dart
Normal file
@ -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<SettingsScreen> createState() => _SettingsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
|
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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,14 +7,24 @@ import Foundation
|
|||||||
|
|
||||||
import audioplayers_darwin
|
import audioplayers_darwin
|
||||||
import bonsoir_darwin
|
import bonsoir_darwin
|
||||||
|
import file_picker
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
|
import flutter_local_notifications
|
||||||
|
import gal
|
||||||
import mobile_scanner
|
import mobile_scanner
|
||||||
|
import shared_preferences_foundation
|
||||||
|
import speech_to_text
|
||||||
import sqlite3_flutter_libs
|
import sqlite3_flutter_libs
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
|
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
|
||||||
SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin"))
|
SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin"))
|
||||||
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
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"))
|
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"))
|
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -229,10 +229,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: file_picker
|
name: file_picker
|
||||||
sha256: "1bbf65dd997458a08b531042ec3794112a6c39c07c37ff22113d2e7e4f81d4e4"
|
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.2.1"
|
version: "8.3.7"
|
||||||
file_selector_linux:
|
file_selector_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -286,6 +286,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0"
|
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:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -304,6 +328,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
gal:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: gal
|
||||||
|
sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.2"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -384,6 +416,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.2"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -520,6 +560,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
pedantic:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pedantic
|
||||||
|
sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.11.1"
|
||||||
permission_handler:
|
permission_handler:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -630,6 +678,62 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.0"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -643,6 +747,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.1"
|
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:
|
sqlite3:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -707,6 +835,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.6"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -9,7 +9,9 @@
|
|||||||
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
||||||
#include <bonsoir_windows/bonsoir_windows_plugin_c_api.h>
|
#include <bonsoir_windows/bonsoir_windows_plugin_c_api.h>
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
|
#include <gal/gal_plugin_c_api.h>
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
|
#include <speech_to_text_windows/speech_to_text_windows.h>
|
||||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
@ -19,8 +21,12 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi"));
|
||||||
FileSelectorWindowsRegisterWithRegistrar(
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
|
GalPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("GalPluginCApi"));
|
||||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
|
SpeechToTextWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("SpeechToTextWindows"));
|
||||||
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,9 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
audioplayers_windows
|
audioplayers_windows
|
||||||
bonsoir_windows
|
bonsoir_windows
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
|
gal
|
||||||
permission_handler_windows
|
permission_handler_windows
|
||||||
|
speech_to_text_windows
|
||||||
sqlite3_flutter_libs
|
sqlite3_flutter_libs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,22 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:flutter/widgets.dart'; // AppLifecycleState
|
||||||
import '../network/network_manager.dart';
|
import '../network/network_manager.dart';
|
||||||
import '../model/play_packet.dart';
|
import '../model/play_packet.dart';
|
||||||
|
import 'notification_manager.dart'; // [New]
|
||||||
|
|
||||||
class ChatMessage {
|
class ChatMessage {
|
||||||
|
final String senderId; // [추가] 유저 조회용
|
||||||
final String senderName;
|
final String senderName;
|
||||||
final String text;
|
final String text;
|
||||||
final bool isMe;
|
final bool isMe;
|
||||||
final DateTime timestamp;
|
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 {
|
class GlobalChatManager {
|
||||||
@ -16,13 +24,11 @@ class GlobalChatManager {
|
|||||||
factory GlobalChatManager() => _instance;
|
factory GlobalChatManager() => _instance;
|
||||||
GlobalChatManager._internal();
|
GlobalChatManager._internal();
|
||||||
|
|
||||||
// UI가 구독할 스트림
|
|
||||||
final _messageController = StreamController<List<ChatMessage>>.broadcast();
|
final _messageController = StreamController<List<ChatMessage>>.broadcast();
|
||||||
Stream<List<ChatMessage>> get messageStream => _messageController.stream;
|
Stream<List<ChatMessage>> get messageStream => _messageController.stream;
|
||||||
|
|
||||||
final List<ChatMessage> _messages = [];
|
final List<ChatMessage> _messages = [];
|
||||||
|
|
||||||
/// [NetworkManager]로부터 패킷을 전달받음
|
|
||||||
void onPacketReceived(PlayPacket packet) {
|
void onPacketReceived(PlayPacket packet) {
|
||||||
if (packet.type != PacketType.chat) return;
|
if (packet.type != PacketType.chat) return;
|
||||||
|
|
||||||
@ -31,25 +37,50 @@ class GlobalChatManager {
|
|||||||
final text = data['text'];
|
final text = data['text'];
|
||||||
final isMe = packet.senderId == NetworkManager().me.id;
|
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);
|
_messages.add(chatMsg);
|
||||||
|
|
||||||
// UI 갱신
|
|
||||||
_messageController.add(List.from(_messages));
|
_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) {
|
void sendMessage(String text) {
|
||||||
if (text.trim().isEmpty) return;
|
if (text.trim().isEmpty) return;
|
||||||
|
|
||||||
final myInfo = NetworkManager().me;
|
final myInfo = NetworkManager().me;
|
||||||
|
|
||||||
// 1. 내 화면에 즉시 추가 (나한테는 네트워크로 안 돌아오므로)
|
final myMsg = ChatMessage(
|
||||||
final myMsg = ChatMessage(myInfo.nickname, text, true);
|
senderId: myInfo.id, // 내 ID
|
||||||
|
senderName: myInfo.nickname,
|
||||||
|
text: text,
|
||||||
|
isMe: true,
|
||||||
|
);
|
||||||
_messages.add(myMsg);
|
_messages.add(myMsg);
|
||||||
_messageController.add(List.from(_messages));
|
_messageController.add(List.from(_messages));
|
||||||
|
|
||||||
// 2. 네트워크 전송 (PlayPacket 포장)
|
|
||||||
final packet = PlayPacket(
|
final packet = PlayPacket(
|
||||||
type: PacketType.chat,
|
type: PacketType.chat,
|
||||||
senderId: myInfo.id,
|
senderId: myInfo.id,
|
||||||
|
|||||||
@ -11,6 +11,29 @@ import '../database/ephemeral_database.dart';
|
|||||||
import '../network/network_manager.dart';
|
import '../network/network_manager.dart';
|
||||||
import '../model/play_packet.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 {
|
class MediaManager {
|
||||||
static final MediaManager _instance = MediaManager._internal();
|
static final MediaManager _instance = MediaManager._internal();
|
||||||
factory MediaManager() => _instance;
|
factory MediaManager() => _instance;
|
||||||
@ -19,44 +42,54 @@ class MediaManager {
|
|||||||
EphemeralDatabase? _db;
|
EphemeralDatabase? _db;
|
||||||
String? _currentRoomId;
|
String? _currentRoomId;
|
||||||
|
|
||||||
// UI에서 갤러리 변경을 감지하기 위한 스트림 (DB 변경 시 자동 발동)
|
final Map<String, _TransferState> _activeTransfers = {};
|
||||||
|
Completer<void>? _ackCompleter;
|
||||||
|
|
||||||
|
// [설정] 안정성을 위해 16KB 사용
|
||||||
|
static const int CHUNK_SIZE = 16 * 1024;
|
||||||
|
|
||||||
Stream<List<MediaItem>> get galleryStream {
|
Stream<List<MediaItem>> get galleryStream {
|
||||||
if (_db == null) return const Stream.empty();
|
if (_db == null) return const Stream.empty();
|
||||||
return _db!.select(_db!.mediaItems).watch(); // watch()는 데이터 변경 시 자동 업데이트됨
|
return _db!.select(_db!.mediaItems).watch();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// [1] 초기화 및 정리 (Lifecycle)
|
// 초기화 및 정리
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// 방 생성/입장 시 호출 (DB 생성)
|
|
||||||
Future<void> initialize(String roomId) async {
|
Future<void> initialize(String roomId) async {
|
||||||
// 기존 DB가 열려있다면 정리
|
|
||||||
await cleanup();
|
await cleanup();
|
||||||
|
|
||||||
_currentRoomId = roomId;
|
_currentRoomId = roomId;
|
||||||
_db = await EphemeralDatabase.create(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<void> cleanup() async {
|
Future<void> cleanup() async {
|
||||||
if (_db != null) {
|
if (_db != null) {
|
||||||
await _db!.close();
|
await _db!.close();
|
||||||
_db = null;
|
_db = null;
|
||||||
}
|
}
|
||||||
|
for (var state in _activeTransfers.values) {
|
||||||
// DB 파일 삭제 (흔적 지우기)
|
await state.fileSink.close();
|
||||||
|
}
|
||||||
|
_activeTransfers.clear();
|
||||||
|
_ackCompleter = null;
|
||||||
|
|
||||||
if (_currentRoomId != null) {
|
if (_currentRoomId != null) {
|
||||||
try {
|
try {
|
||||||
final dbFolder = await getApplicationDocumentsDirectory();
|
final dbFolder = await getApplicationDocumentsDirectory();
|
||||||
// EphemeralDatabase.create에서 만든 파일명과 동일해야 함
|
final dbFile = File('${dbFolder.path}/room_$_currentRoomId.sqlite');
|
||||||
final file = File('${dbFolder.path}/room_$_currentRoomId.sqlite');
|
if (await dbFile.exists()) await dbFile.delete();
|
||||||
|
|
||||||
if (await file.exists()) {
|
final tempDir = await getTemporaryDirectory();
|
||||||
await file.delete();
|
final roomDir = Directory('${tempDir.path}/rooms/$_currentRoomId');
|
||||||
print("[MediaManager] DB File Deleted 🔥");
|
if (await roomDir.exists()) await roomDir.delete(recursive: true);
|
||||||
}
|
print("[MediaManager] Cleaned up 🔥");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("[MediaManager] Cleanup Error: $e");
|
print("[MediaManager] Cleanup Error: $e");
|
||||||
}
|
}
|
||||||
@ -65,18 +98,23 @@ class MediaManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// [2] 미디어 전송 (Send)
|
// 미디어 전송 (Stop-and-Wait ARQ)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
Future<void> sendMedia({
|
Future<void> sendMedia({
|
||||||
required String filePath,
|
required String filePath,
|
||||||
required String type, // 'IMAGE', 'AUDIO'
|
required String type,
|
||||||
}) async {
|
}) async {
|
||||||
if (_db == null) return;
|
if (_db == null || _currentRoomId == null) return;
|
||||||
|
|
||||||
final myInfo = NetworkManager().me;
|
final myInfo = NetworkManager().me;
|
||||||
final mediaId = const Uuid().v4();
|
final mediaId = const Uuid().v4();
|
||||||
|
final file = File(filePath);
|
||||||
// A. 내 로컬 DB에 먼저 저장 (내가 보낸 것도 보여야 하니까)
|
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(
|
await _db!.insertMedia(MediaItemsCompanion(
|
||||||
id: drift.Value(mediaId),
|
id: drift.Value(mediaId),
|
||||||
senderId: drift.Value(myInfo.id),
|
senderId: drift.Value(myInfo.id),
|
||||||
@ -86,64 +124,156 @@ class MediaManager {
|
|||||||
createdAt: drift.Value(DateTime.now()),
|
createdAt: drift.Value(DateTime.now()),
|
||||||
));
|
));
|
||||||
|
|
||||||
// B. 파일 읽기 및 인코딩 (MVP: Base64)
|
// 헤더 전송
|
||||||
// 주의: 대용량 동영상은 이 방식으로 보내면 앱 멈춤. (추후 Chunk 방식 개선 필요)
|
await _sendPacketAndWaitAck(PlayPacket(
|
||||||
final file = File(filePath);
|
|
||||||
final fileBytes = await file.readAsBytes();
|
|
||||||
final base64Data = base64Encode(fileBytes);
|
|
||||||
final fileName = filePath.split('/').last;
|
|
||||||
|
|
||||||
// C. 패킷 전송
|
|
||||||
final packet = PlayPacket(
|
|
||||||
type: PacketType.media,
|
type: PacketType.media,
|
||||||
senderId: myInfo.id,
|
senderId: myInfo.id,
|
||||||
|
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
payload: {
|
payload: {
|
||||||
'id': mediaId,
|
'step': 'HEADER',
|
||||||
|
'mediaId': mediaId,
|
||||||
|
'fileName': fileName,
|
||||||
'senderName': myInfo.nickname,
|
'senderName': myInfo.nickname,
|
||||||
'type': type,
|
'type': type,
|
||||||
'data': base64Data,
|
'totalChunks': totalChunks,
|
||||||
'fileName': fileName,
|
'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<int> 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<void> _sendPacketAndWaitAck(PlayPacket packet) async {
|
||||||
|
_ackCompleter = Completer<void>();
|
||||||
NetworkManager().sendPacket(packet);
|
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<void> onMediaReceived(PlayPacket packet) async {
|
Future<void> onMediaReceived(PlayPacket packet) async {
|
||||||
|
final data = packet.payload as Map<String, dynamic>;
|
||||||
|
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;
|
if (_db == null) return;
|
||||||
|
|
||||||
try {
|
final String mediaId = data['mediaId'];
|
||||||
final data = packet.payload as Map<String, dynamic>;
|
|
||||||
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);
|
|
||||||
|
|
||||||
// B. DB에 메타데이터 저장 -> watch() 중인 UI가 자동 업데이트됨
|
try {
|
||||||
await _db!.insertMedia(MediaItemsCompanion(
|
if (step == 'HEADER') {
|
||||||
id: drift.Value(data['id']),
|
final String fileName = data['fileName'];
|
||||||
senderId: drift.Value(packet.senderId),
|
final tempDir = await getTemporaryDirectory();
|
||||||
senderName: drift.Value(data['senderName']),
|
final savePath = '${tempDir.path}/rooms/$_currentRoomId/${const Uuid().v4()}_$fileName';
|
||||||
type: drift.Value(data['type']),
|
|
||||||
filePath: drift.Value(savePath),
|
final file = File(savePath);
|
||||||
createdAt: drift.Value(DateTime.fromMillisecondsSinceEpoch(packet.timestamp)),
|
await file.create(recursive: true);
|
||||||
));
|
final sink = file.openWrite();
|
||||||
|
|
||||||
print("[MediaManager] File Saved: $savePath");
|
_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<int> bytes = base64Decode(base64Data);
|
||||||
|
|
||||||
|
state.fileSink.add(bytes);
|
||||||
|
state.receivedChunks++;
|
||||||
|
|
||||||
|
_sendAck(mediaId);
|
||||||
|
|
||||||
|
if (state.receivedChunks >= state.totalChunks) {
|
||||||
|
await _finishTransfer(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("[MediaManager] Receive Error: $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<void> _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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
70
packages/core/lib/manager/notification_manager.dart
Normal file
70
packages/core/lib/manager/notification_manager.dart
Normal file
@ -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<void> 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<void> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
packages/core/lib/manager/settings_manager.dart
Normal file
149
packages/core/lib/manager/settings_manager.dart
Normal file
@ -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<String, MaterialColor> 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<void> _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<void> 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<void> 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<void> clearProfileImage() async {
|
||||||
|
_profileImageBase64 = null;
|
||||||
|
notifyListeners();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_keyProfileImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테마 색상 설정
|
||||||
|
Future<void> setThemeColor(String colorName) async {
|
||||||
|
if (!appColors.containsKey(colorName)) return;
|
||||||
|
_themeColorName = colorName;
|
||||||
|
notifyListeners();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyThemeColor, colorName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다크 모드 토글
|
||||||
|
Future<void> toggleDarkMode(bool value) async {
|
||||||
|
_isDarkMode = value;
|
||||||
|
notifyListeners();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyDarkMode, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 폰트 크기 설정
|
||||||
|
Future<void> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
packages/core/lib/manager/voice_manager.dart
Normal file
100
packages/core/lib/manager/voice_manager.dart
Normal file
@ -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<String>.broadcast();
|
||||||
|
Stream<String> get resultStream => _resultController.stream;
|
||||||
|
|
||||||
|
// 현재 듣고 있는지 여부
|
||||||
|
bool get isListening => _speech.isListening;
|
||||||
|
|
||||||
|
/// 초기화 (앱 시작 시 또는 게임 진입 시 호출)
|
||||||
|
Future<bool> 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<void> 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<void> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,14 +5,16 @@ class UserInfo extends Equatable {
|
|||||||
final String nickname;
|
final String nickname;
|
||||||
final int avatarIndex;
|
final int avatarIndex;
|
||||||
final int colorValue;
|
final int colorValue;
|
||||||
final bool isReady; // [추가] 준비 상태
|
final bool isReady;
|
||||||
|
final String? profileImageBase64; // [추가] 커스텀 프로필 이미지 (Base64)
|
||||||
|
|
||||||
const UserInfo({
|
const UserInfo({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.nickname,
|
required this.nickname,
|
||||||
this.avatarIndex = 0,
|
this.avatarIndex = 0,
|
||||||
this.colorValue = 0xFF2196F3,
|
this.colorValue = 0xFF2196F3,
|
||||||
this.isReady = false, // 기본값 false
|
this.isReady = false,
|
||||||
|
this.profileImageBase64, // 생성자 추가
|
||||||
});
|
});
|
||||||
|
|
||||||
factory UserInfo.fromJson(Map<String, dynamic> json) {
|
factory UserInfo.fromJson(Map<String, dynamic> json) {
|
||||||
@ -21,7 +23,8 @@ class UserInfo extends Equatable {
|
|||||||
nickname: json['nickname'] as String,
|
nickname: json['nickname'] as String,
|
||||||
avatarIndex: json['avatarIndex'] as int? ?? 0,
|
avatarIndex: json['avatarIndex'] as int? ?? 0,
|
||||||
colorValue: json['colorValue'] as int? ?? 0xFF2196F3,
|
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,
|
'nickname': nickname,
|
||||||
'avatarIndex': avatarIndex,
|
'avatarIndex': avatarIndex,
|
||||||
'colorValue': colorValue,
|
'colorValue': colorValue,
|
||||||
'isReady': isReady, // JSON 변환 추가
|
'isReady': isReady,
|
||||||
|
'profileImageBase64': profileImageBase64, // 변환 추가
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,7 +44,8 @@ class UserInfo extends Equatable {
|
|||||||
String? nickname,
|
String? nickname,
|
||||||
int? avatarIndex,
|
int? avatarIndex,
|
||||||
int? colorValue,
|
int? colorValue,
|
||||||
bool? isReady, // copyWith 추가
|
bool? isReady,
|
||||||
|
String? profileImageBase64, // copyWith 추가
|
||||||
}) {
|
}) {
|
||||||
return UserInfo(
|
return UserInfo(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@ -48,9 +53,10 @@ class UserInfo extends Equatable {
|
|||||||
avatarIndex: avatarIndex ?? this.avatarIndex,
|
avatarIndex: avatarIndex ?? this.avatarIndex,
|
||||||
colorValue: colorValue ?? this.colorValue,
|
colorValue: colorValue ?? this.colorValue,
|
||||||
isReady: isReady ?? this.isReady,
|
isReady: isReady ?? this.isReady,
|
||||||
|
profileImageBase64: profileImageBase64 ?? this.profileImageBase64,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [id, nickname, avatarIndex, colorValue, isReady];
|
List<Object?> get props => [id, nickname, avatarIndex, colorValue, isReady, profileImageBase64];
|
||||||
}
|
}
|
||||||
@ -7,11 +7,11 @@ import 'package:bonsoir/bonsoir.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../manager/notification_manager.dart';
|
||||||
import '../model/user_info.dart';
|
import '../model/user_info.dart';
|
||||||
import '../model/play_packet.dart';
|
import '../model/play_packet.dart';
|
||||||
import '../manager/global_chat_manager.dart';
|
import '../manager/global_chat_manager.dart';
|
||||||
import '../manager/media_manager.dart'; // [New] 미디어 매니저
|
import '../manager/media_manager.dart';
|
||||||
|
|
||||||
enum NetworkRole { none, host, guest }
|
enum NetworkRole { none, host, guest }
|
||||||
|
|
||||||
@ -23,6 +23,16 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
WidgetsBinding.instance.addObserver(this);
|
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;
|
ServerSocket? _serverSocket;
|
||||||
Socket? _clientSocket;
|
Socket? _clientSocket;
|
||||||
|
|
||||||
// 소켓과 유저 정보를 1:1 매핑
|
|
||||||
final Map<Socket, UserInfo?> _connectedGuests = {};
|
final Map<Socket, UserInfo?> _connectedGuests = {};
|
||||||
|
|
||||||
// UI 표시용 게스트 명단
|
|
||||||
final List<UserInfo> guestList = [];
|
final List<UserInfo> guestList = [];
|
||||||
|
|
||||||
|
// [버퍼] 소켓별로 들어오다 만 데이터를 저장
|
||||||
|
final Map<Socket, String> _packetBuffers = {};
|
||||||
|
|
||||||
BonsoirService? _bonsoirService;
|
BonsoirService? _bonsoirService;
|
||||||
BonsoirBroadcast? _bonsoirBroadcast;
|
BonsoirBroadcast? _bonsoirBroadcast;
|
||||||
BonsoirDiscovery? _bonsoirDiscovery;
|
BonsoirDiscovery? _bonsoirDiscovery;
|
||||||
|
|
||||||
// 게임 데이터 스트림
|
|
||||||
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
|
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
|
||||||
|
|
||||||
// 로그 스트림
|
|
||||||
final _logController = StreamController<String>.broadcast();
|
final _logController = StreamController<String>.broadcast();
|
||||||
Stream<String> get logStream => _logController.stream;
|
Stream<String> get logStream => _logController.stream;
|
||||||
|
|
||||||
// 하트비트 & 재접속
|
|
||||||
Timer? _heartbeatTimer;
|
Timer? _heartbeatTimer;
|
||||||
Timer? _disconnectWaitTimer;
|
Timer? _disconnectWaitTimer;
|
||||||
DateTime? _lastPongTime;
|
DateTime? _lastPongTime;
|
||||||
bool _isReconnecting = false;
|
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 uuid = const Uuid().v4().substring(0, 8);
|
||||||
final randomColor = 0xFF000000 | (nickname.hashCode & 0xFFFFFF);
|
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}");
|
_log("초기화 완료: ${me.nickname}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,7 +173,6 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!);
|
_bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!);
|
||||||
await _bonsoirBroadcast!.start();
|
await _bonsoirBroadcast!.start();
|
||||||
|
|
||||||
// [NEW] 미디어 DB 초기화 (Host)
|
|
||||||
await MediaManager().initialize(roomName);
|
await MediaManager().initialize(roomName);
|
||||||
|
|
||||||
_startHeartbeat();
|
_startHeartbeat();
|
||||||
@ -176,6 +187,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
void _handleNewGuest(Socket client) {
|
void _handleNewGuest(Socket client) {
|
||||||
_log("🎉 연결됨: ${client.remoteAddress.address}");
|
_log("🎉 연결됨: ${client.remoteAddress.address}");
|
||||||
_connectedGuests[client] = null;
|
_connectedGuests[client] = null;
|
||||||
|
_packetBuffers[client] = ""; // 버퍼 초기화
|
||||||
|
|
||||||
client.listen(
|
client.listen(
|
||||||
(Uint8List data) => _onDataReceived(client, data),
|
(Uint8List data) => _onDataReceived(client, data),
|
||||||
@ -191,6 +203,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
guestList.removeWhere((u) => u.id == user.id);
|
guestList.removeWhere((u) => u.id == user.id);
|
||||||
}
|
}
|
||||||
_connectedGuests.remove(client);
|
_connectedGuests.remove(client);
|
||||||
|
_packetBuffers.remove(client);
|
||||||
client.close();
|
client.close();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@ -240,10 +253,11 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_log("🚀 접속 시도: $ip:$port");
|
_log("🚀 접속 시도: $ip:$port");
|
||||||
_clientSocket = await Socket.connect(ip, port, timeout: const Duration(seconds: 5));
|
_clientSocket = await Socket.connect(ip, port, timeout: const Duration(seconds: 5));
|
||||||
_log("✅ 접속 성공!");
|
_log("✅ 접속 성공!");
|
||||||
|
|
||||||
|
_packetBuffers[_clientSocket!] = ""; // 내 버퍼 초기화
|
||||||
|
|
||||||
sendMessage({'type': 'HANDSHAKE', 'payload': me.toJson()});
|
sendMessage({'type': 'HANDSHAKE', 'payload': me.toJson()});
|
||||||
|
|
||||||
// [NEW] 미디어 DB 초기화 (Guest는 임시 ID 사용)
|
|
||||||
await MediaManager().initialize("guest_${ip.replaceAll('.', '_')}");
|
await MediaManager().initialize("guest_${ip.replaceAll('.', '_')}");
|
||||||
|
|
||||||
_lastPongTime = DateTime.now();
|
_lastPongTime = DateTime.now();
|
||||||
@ -265,7 +279,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
// 데이터 송수신 & 라우팅 (핵심)
|
// 데이터 송수신 (버퍼링 및 구분자 로직 강화)
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
void sendPacket(PlayPacket packet) {
|
void sendPacket(PlayPacket packet) {
|
||||||
sendMessage(packet.toJson());
|
sendMessage(packet.toJson());
|
||||||
@ -275,6 +289,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
if (role == NetworkRole.guest && _clientSocket == null) return;
|
if (role == NetworkRole.guest && _clientSocket == null) return;
|
||||||
|
|
||||||
final jsonString = jsonEncode(messageMap);
|
final jsonString = jsonEncode(messageMap);
|
||||||
|
|
||||||
// 로그 필터링
|
// 로그 필터링
|
||||||
if (messageMap['type'] != 'PING' && messageMap['type'] != 'PONG') {
|
if (messageMap['type'] != 'PING' && messageMap['type'] != 'PONG') {
|
||||||
if (messageMap['type'] == 'chat') {
|
if (messageMap['type'] == 'chat') {
|
||||||
@ -286,7 +301,10 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<int> data = utf8.encode('$jsonString\n');
|
// [핵심] 메시지 뒤에 고유 구분자를 붙여서 전송
|
||||||
|
final fullMessage = '$jsonString$PACKET_DELIMITER';
|
||||||
|
final List<int> data = utf8.encode(fullMessage);
|
||||||
|
|
||||||
if (role == NetworkRole.host) {
|
if (role == NetworkRole.host) {
|
||||||
for (var socket in _connectedGuests.keys) {
|
for (var socket in _connectedGuests.keys) {
|
||||||
socket.add(data);
|
socket.add(data);
|
||||||
@ -297,86 +315,98 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onDataReceived(Socket socket, Uint8List data) {
|
void _onDataReceived(Socket socket, Uint8List data) {
|
||||||
final String rawString = utf8.decode(data);
|
try {
|
||||||
final List<String> splitMessages = rawString.split('\n');
|
// 1. 기존 버퍼 가져오기
|
||||||
|
String buffer = _packetBuffers[socket] ?? "";
|
||||||
for (var msg in splitMessages) {
|
// 2. 새 데이터 추가
|
||||||
if (msg.trim().isEmpty) continue;
|
buffer += utf8.decode(data, allowMalformed: true);
|
||||||
|
|
||||||
try {
|
|
||||||
final Map<String, dynamic> jsonMap = jsonDecode(msg);
|
|
||||||
|
|
||||||
// 1. 시스템 메시지 (Ping/Pong)
|
// 3. 구분자(PACKET_DELIMITER)를 기준으로 메시지 추출
|
||||||
if (jsonMap['type'] == 'PING') {
|
while (buffer.contains(PACKET_DELIMITER)) {
|
||||||
sendMessage({'type': 'PONG'});
|
final int delimiterIndex = buffer.indexOf(PACKET_DELIMITER);
|
||||||
_lastPongTime = DateTime.now();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (jsonMap['type'] == 'PONG') {
|
|
||||||
_lastPongTime = DateTime.now();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 핸드셰이크
|
// 완성된 메시지 하나 추출
|
||||||
if (jsonMap['type'] == 'HANDSHAKE') {
|
final String msg = buffer.substring(0, delimiterIndex);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 게임 시작
|
// 버퍼에서 추출한 부분과 구분자 제거
|
||||||
if (jsonMap['type'] == 'GAME_START') {
|
buffer = buffer.substring(delimiterIndex + PACKET_DELIMITER.length);
|
||||||
_resetAllReadyState();
|
|
||||||
_messageController.add(jsonMap);
|
// 메시지 처리
|
||||||
return;
|
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<String, dynamic> 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) {
|
void _handleConnectionLost(dynamic reason) {
|
||||||
if (role != NetworkRole.guest) return;
|
if (role != NetworkRole.guest) return;
|
||||||
|
|
||||||
_log("⚠️ 연결 끊김: $reason");
|
_log("⚠️ 연결 끊김: $reason");
|
||||||
|
|
||||||
_clientSocket?.destroy();
|
_clientSocket?.destroy();
|
||||||
@ -392,13 +423,32 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
|
|
||||||
if (_disconnectWaitTimer != null && _disconnectWaitTimer!.isActive) return;
|
if (_disconnectWaitTimer != null && _disconnectWaitTimer!.isActive) return;
|
||||||
|
|
||||||
|
// 5초 카운트다운 시작
|
||||||
_disconnectWaitTimer = Timer(const Duration(seconds: RECONNECT_WAIT_SEC), () {
|
_disconnectWaitTimer = Timer(const Duration(seconds: RECONNECT_WAIT_SEC), () {
|
||||||
_log("💀 복구 실패. 종료.");
|
_log("💀 복구 실패. 종료.");
|
||||||
|
|
||||||
|
// [추가] 재접속 실패 알림 발송
|
||||||
|
_sendDisconnectionNotification();
|
||||||
|
|
||||||
stopNetwork(force: true);
|
stopNetwork(force: true);
|
||||||
});
|
});
|
||||||
|
|
||||||
_attemptReconnection();
|
_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<void> _attemptReconnection() async {
|
Future<void> _attemptReconnection() async {
|
||||||
if (hostIp == null || hostPort == null) return;
|
if (hostIp == null || hostPort == null) return;
|
||||||
_isReconnecting = true;
|
_isReconnecting = true;
|
||||||
@ -451,7 +501,6 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
if (!force && _disconnectWaitTimer != null) return;
|
if (!force && _disconnectWaitTimer != null) return;
|
||||||
|
|
||||||
_log("🛑 종료");
|
_log("🛑 종료");
|
||||||
// [NEW] 미디어 DB 정리
|
|
||||||
MediaManager().cleanup();
|
MediaManager().cleanup();
|
||||||
|
|
||||||
_heartbeatTimer?.cancel();
|
_heartbeatTimer?.cancel();
|
||||||
@ -461,9 +510,12 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_bonsoirDiscovery?.stop();
|
_bonsoirDiscovery?.stop();
|
||||||
_serverSocket?.close();
|
_serverSocket?.close();
|
||||||
_clientSocket?.close();
|
_clientSocket?.close();
|
||||||
|
|
||||||
for (var s in _connectedGuests.keys) s.close();
|
for (var s in _connectedGuests.keys) s.close();
|
||||||
_connectedGuests.clear();
|
_connectedGuests.clear();
|
||||||
|
_packetBuffers.clear(); // 버퍼 정리
|
||||||
guestList.clear();
|
guestList.clear();
|
||||||
|
|
||||||
role = NetworkRole.none;
|
role = NetworkRole.none;
|
||||||
_serverSocket = null;
|
_serverSocket = null;
|
||||||
_clientSocket = null;
|
_clientSocket = null;
|
||||||
|
|||||||
@ -6,9 +6,13 @@ export 'model/user_info.dart';
|
|||||||
export 'model/play_packet.dart';
|
export 'model/play_packet.dart';
|
||||||
export 'utils/sound_manager.dart';
|
export 'utils/sound_manager.dart';
|
||||||
export 'manager/global_chat_manager.dart';
|
export 'manager/global_chat_manager.dart';
|
||||||
export 'widgets/game_chat_overlay.dart';
|
export 'manager/media_manager.dart';
|
||||||
|
export 'manager/settings_manager.dart';
|
||||||
// [추가] DB 관련 (Drift가 생성한 데이터 클래스들도 쓰기 위해)
|
|
||||||
export 'database/ephemeral_database.dart';
|
export 'database/ephemeral_database.dart';
|
||||||
// Drift의 기본 타입(Value 등)을 쓰려면 아래 줄도 필요할 수 있음 (선택)
|
|
||||||
export 'package:drift/drift.dart' show Value;
|
// [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'; // 추가
|
||||||
73
packages/core/lib/widgets/avatar_widget.dart
Normal file
73
packages/core/lib/widgets/avatar_widget.dart
Normal file
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,9 +1,14 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gal/gal.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import '../manager/global_chat_manager.dart';
|
import '../manager/global_chat_manager.dart';
|
||||||
import '../manager/media_manager.dart'; // 미디어 매니저
|
import '../manager/media_manager.dart';
|
||||||
import '../database/ephemeral_database.dart'; // DB 모델
|
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 {
|
class GameChatOverlay extends StatefulWidget {
|
||||||
const GameChatOverlay({super.key});
|
const GameChatOverlay({super.key});
|
||||||
@ -15,17 +20,88 @@ class GameChatOverlay extends StatefulWidget {
|
|||||||
class _GameChatOverlayState extends State<GameChatOverlay> {
|
class _GameChatOverlayState extends State<GameChatOverlay> {
|
||||||
final TextEditingController _textController = TextEditingController();
|
final TextEditingController _textController = TextEditingController();
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
bool _isExpanded = false;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// [핵심 수정] 키보드가 올라왔을 때 그 높이만큼 값을 가져옴
|
||||||
|
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
|
||||||
|
|
||||||
return Align(
|
return Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 200), // 반응 속도를 위해 조금 빠르게 조정
|
||||||
// 높이 조정: 미디어 갤러리가 보일 공간 확보 (펼쳤을 때)
|
height: _isExpanded ? 500 : 60,
|
||||||
height: _isExpanded ? 500 : 60,
|
|
||||||
margin: const EdgeInsets.all(10),
|
// [핵심 수정] 기존 마진(10)에 키보드 높이(bottomPadding)를 더해줌
|
||||||
|
// 이렇게 하면 키보드가 올라올 때 채팅창도 같이 올라갑니다.
|
||||||
|
margin: EdgeInsets.only(
|
||||||
|
left: 10,
|
||||||
|
right: 10,
|
||||||
|
bottom: 10 + bottomPadding,
|
||||||
|
),
|
||||||
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withOpacity(0.85),
|
color: Colors.black.withOpacity(0.85),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@ -33,59 +109,97 @@ class _GameChatOverlayState extends State<GameChatOverlay> {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 1. 상단 핸들 (접기/펼치기)
|
// 1. 상단 핸들
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => setState(() => _isExpanded = !_isExpanded),
|
onTap: _toggleExpand,
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 30,
|
height: 60,
|
||||||
alignment: Alignment.center,
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: Icon(
|
alignment: Alignment.centerLeft,
|
||||||
_isExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up,
|
child: Row(
|
||||||
color: Colors.white,
|
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) ...[
|
if (_isExpanded) ...[
|
||||||
|
const Divider(height: 1, color: Colors.white24),
|
||||||
|
|
||||||
// 2. 미디어 갤러리 (가로 스크롤)
|
// 미디어 갤러리
|
||||||
// DB의 변경사항을 실시간으로 감지(Stream)하여 보여줌
|
Container(
|
||||||
SizedBox(
|
height: 110,
|
||||||
height: 100,
|
width: double.infinity,
|
||||||
|
color: Colors.black12,
|
||||||
child: StreamBuilder<List<MediaItem>>(
|
child: StreamBuilder<List<MediaItem>>(
|
||||||
stream: MediaManager().galleryStream,
|
stream: MediaManager().galleryStream,
|
||||||
|
initialData: const [],
|
||||||
builder: (context, snapshot) {
|
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) {
|
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(
|
return ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
padding: const EdgeInsets.all(10),
|
||||||
itemCount: mediaList.length,
|
itemCount: mediaList.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = mediaList[index];
|
final item = mediaList[index];
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(right: 8.0),
|
padding: const EdgeInsets.only(right: 10),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => _showFullImage(context, item),
|
onTap: () => _showFullImage(context, item),
|
||||||
child: ClipRRect(
|
child: Column(
|
||||||
borderRadius: BorderRadius.circular(8),
|
children: [
|
||||||
child: Image.file(
|
ClipRRect(
|
||||||
File(item.filePath),
|
borderRadius: BorderRadius.circular(8),
|
||||||
width: 100,
|
child: Image.file(
|
||||||
height: 100,
|
File(item.filePath),
|
||||||
fit: BoxFit.cover,
|
width: 70, height: 70,
|
||||||
errorBuilder: (_,__,___) => Container(
|
fit: BoxFit.cover,
|
||||||
width: 100, height: 100, color: Colors.grey,
|
errorBuilder: (_,__,___) => Container(
|
||||||
child: const Icon(Icons.broken_image),
|
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<GameChatOverlay> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const Divider(color: Colors.white24),
|
const Divider(height: 1, color: Colors.white24),
|
||||||
|
|
||||||
// 3. 채팅 리스트
|
// 채팅 리스트
|
||||||
|
// 3. 채팅 리스트 부분
|
||||||
Expanded(
|
Expanded(
|
||||||
child: StreamBuilder<List<ChatMessage>>(
|
child: StreamBuilder<List<ChatMessage>>(
|
||||||
stream: GlobalChatManager().messageStream,
|
stream: GlobalChatManager().messageStream,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final messages = snapshot.data ?? [];
|
final messages = snapshot.data ?? [];
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
// ... (스크롤 로직 동일) ...
|
||||||
if (_scrollController.hasClients) {
|
|
||||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
padding: const EdgeInsets.all(10),
|
||||||
itemCount: messages.length,
|
itemCount: messages.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final msg = messages[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(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: Text(
|
child: Row(
|
||||||
"${msg.senderName}: ${msg.text}",
|
mainAxisAlignment: msg.isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||||
style: TextStyle(
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
color: msg.isMe ? Colors.yellow : Colors.white,
|
children: [
|
||||||
fontSize: 14,
|
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<GameChatOverlay> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 4. 입력창 (+ 미디어 버튼)
|
// 입력창
|
||||||
Padding(
|
Container(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
color: Colors.black54,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// [추가] 이미지 전송 버튼
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.add_photo_alternate, color: Colors.blueAccent),
|
icon: const Icon(Icons.add_photo_alternate, color: Colors.blueAccent),
|
||||||
onPressed: _pickAndSendImage,
|
onPressed: _pickAndSendImage,
|
||||||
@ -145,10 +294,10 @@ class _GameChatOverlayState extends State<GameChatOverlay> {
|
|||||||
controller: _textController,
|
controller: _textController,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: "채팅 입력...",
|
hintText: "메시지 보내기...",
|
||||||
hintStyle: TextStyle(color: Colors.white54),
|
hintStyle: TextStyle(color: Colors.white54),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
isDense: true,
|
contentPadding: EdgeInsets.symmetric(horizontal: 10),
|
||||||
),
|
),
|
||||||
onSubmitted: _sendMessage,
|
onSubmitted: _sendMessage,
|
||||||
),
|
),
|
||||||
@ -173,26 +322,19 @@ class _GameChatOverlayState extends State<GameChatOverlay> {
|
|||||||
_textController.clear();
|
_textController.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// [이미지 선택 및 전송 로직]
|
|
||||||
Future<void> _pickAndSendImage() async {
|
Future<void> _pickAndSendImage() async {
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
// 갤러리에서 이미지 선택 (압축 옵션 추가 권장)
|
|
||||||
final XFile? image = await picker.pickImage(
|
final XFile? image = await picker.pickImage(
|
||||||
source: ImageSource.gallery,
|
source: ImageSource.gallery,
|
||||||
imageQuality: 50, // 전송 속도를 위해 품질 낮춤
|
imageQuality: 70,
|
||||||
maxWidth: 800,
|
maxWidth: 1024,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (image != null) {
|
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) {
|
void _showFullImage(BuildContext context, MediaItem item) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@ -202,23 +344,33 @@ class _GameChatOverlayState extends State<GameChatOverlay> {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
InteractiveViewer(
|
InteractiveViewer(child: Image.file(File(item.filePath))),
|
||||||
child: Image.file(File(item.filePath)),
|
|
||||||
),
|
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 40,
|
top: 40, left: 20, right: 20,
|
||||||
right: 20,
|
child: Row(
|
||||||
child: IconButton(
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
icon: const Icon(Icons.close, color: Colors.white, size: 30),
|
children: [
|
||||||
onPressed: () => Navigator.pop(ctx),
|
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(
|
Positioned(
|
||||||
bottom: 20,
|
bottom: 20,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
color: Colors.black54,
|
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<GameChatOverlay> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
71
packages/core/lib/widgets/voice_widget.dart
Normal file
71
packages/core/lib/widgets/voice_widget.dart
Normal file
@ -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<VoiceWidget> createState() => _VoiceWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VoiceWidgetState extends State<VoiceWidget> 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,11 +18,13 @@ dependencies:
|
|||||||
sqlite3_flutter_libs: ^0.5.0
|
sqlite3_flutter_libs: ^0.5.0
|
||||||
path_provider: ^2.1.1
|
path_provider: ^2.1.1
|
||||||
path: ^1.8.3
|
path: ^1.8.3
|
||||||
|
gal: ^2.3.0 # [추가] 갤러리 저장용
|
||||||
# [파일 피커]
|
# [파일 피커]
|
||||||
image_picker: ^1.0.4
|
image_picker: ^1.1.2
|
||||||
file_picker: ^6.1.1
|
file_picker: ^8.1.4
|
||||||
|
shared_preferences: ^2.2.2
|
||||||
|
speech_to_text: ^7.0.0
|
||||||
|
flutter_local_notifications: ^17.0.0
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
drift_dev: ^2.13.0
|
drift_dev: ^2.13.0
|
||||||
build_runner: ^2.4.6
|
build_runner: ^2.4.6
|
||||||
@ -12,32 +12,33 @@ class QuizGame extends BaseGame {
|
|||||||
String get name => "OX 퀴즈 서바이벌";
|
String get name => "OX 퀴즈 서바이벌";
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get description => "방장도 플레이어! 3초 안에 선택하세요.";
|
String get description => "끝까지 살아남으세요!";
|
||||||
|
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
// 상태 변수
|
// 상태 변수
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
final _gameStateController = StreamController<Map<String, dynamic>>.broadcast();
|
final _gameStateController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
Stream<Map<String, dynamic>> get gameStateStream => _gameStateController.stream;
|
Stream<Map<String, dynamic>> get gameStateStream => _gameStateController.stream;
|
||||||
StreamSubscription? _networkSubscription;
|
|
||||||
|
|
||||||
// UI 초기화 지연 방지용 데이터
|
// UI 초기화 지연 방지용 데이터
|
||||||
Map<String, dynamic>? _lastState;
|
Map<String, dynamic>? _lastState;
|
||||||
|
|
||||||
// 게임 데이터
|
final Set<String> _aliveUsers = {};
|
||||||
final Set<String> _aliveUsers = {}; // 생존자 ID
|
final Set<String> _answeredUsers = {};
|
||||||
final Set<String> _answeredUsers = {}; // 답변 제출자 ID
|
|
||||||
|
|
||||||
// 나의 상태
|
|
||||||
PlayerStatus _myStatus = PlayerStatus.alive;
|
PlayerStatus _myStatus = PlayerStatus.alive;
|
||||||
String? _mySelectedAnswer;
|
String? _mySelectedAnswer;
|
||||||
bool _isLockedIn = false;
|
bool _isLockedIn = false;
|
||||||
Timer? _lockInTimer;
|
Timer? _lockInTimer;
|
||||||
|
|
||||||
// 카운트다운 상태
|
// [상태] 카운트다운 중인가?
|
||||||
bool _isCountingDown = false;
|
bool _isCountingDown = false;
|
||||||
int _countdownValue = 3;
|
int _countdownValue = 3;
|
||||||
|
|
||||||
|
// [상태] 정답 공개 중인가? (중간 대기 화면)
|
||||||
|
bool _isShowingResult = false;
|
||||||
|
String _currentCorrectAnswer = "";
|
||||||
|
|
||||||
final List<Map<String, dynamic>> _questions = [
|
final List<Map<String, dynamic>> _questions = [
|
||||||
{"q": "사과는 영어로 Apple이다.", "a": "O"},
|
{"q": "사과는 영어로 Apple이다.", "a": "O"},
|
||||||
{"q": "바나나는 길어지면 기차다.", "a": "X"},
|
{"q": "바나나는 길어지면 기차다.", "a": "X"},
|
||||||
@ -54,25 +55,23 @@ class QuizGame extends BaseGame {
|
|||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
@override
|
@override
|
||||||
void onStart() {
|
void onStart() {
|
||||||
|
super.onStart();
|
||||||
print("Quiz Game Started!");
|
print("Quiz Game Started!");
|
||||||
|
|
||||||
_resetLocalState();
|
_resetLocalState();
|
||||||
_lastState = null;
|
_lastState = null;
|
||||||
_aliveUsers.clear();
|
_aliveUsers.clear();
|
||||||
|
|
||||||
// 참가자 명단 초기화 (나 + 게스트)
|
|
||||||
_aliveUsers.add(NetworkManager().me.id);
|
_aliveUsers.add(NetworkManager().me.id);
|
||||||
for (var guest in NetworkManager().guestList) {
|
for (var guest in NetworkManager().guestList) {
|
||||||
_aliveUsers.add(guest.id);
|
_aliveUsers.add(guest.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
_networkSubscription = NetworkManager().messageStream.listen((payload) {
|
// [Host] 게임 시작 시퀀스 진입
|
||||||
onMessageReceived("", payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
// [Host] 잠시 후 카운트다운 시작
|
|
||||||
if (NetworkManager().role == NetworkRole.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
|
@override
|
||||||
void onDispose() {
|
void onDispose() {
|
||||||
_lockInTimer?.cancel();
|
_lockInTimer?.cancel();
|
||||||
_networkSubscription?.cancel();
|
|
||||||
_gameStateController.close();
|
_gameStateController.close();
|
||||||
}
|
super.onDispose();
|
||||||
|
|
||||||
// [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});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
@ -110,11 +93,15 @@ class QuizGame extends BaseGame {
|
|||||||
_lastState = payload;
|
_lastState = payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. [Common] 카운트다운
|
// 1. [Common] 카운트다운 수신
|
||||||
if (payload['type'] == 'GAME_COUNTDOWN') {
|
if (payload['type'] == 'GAME_COUNTDOWN') {
|
||||||
|
_isShowingResult = false; // 결과 화면 끄기
|
||||||
_isCountingDown = true;
|
_isCountingDown = true;
|
||||||
_countdownValue = payload['count'];
|
_countdownValue = payload['count'];
|
||||||
|
|
||||||
|
// 3, 2, 1 소리
|
||||||
SoundManager().playSfx(SoundKey.click);
|
SoundManager().playSfx(SoundKey.click);
|
||||||
|
|
||||||
_gameStateController.add(payload);
|
_gameStateController.add(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,12 +117,11 @@ class QuizGame extends BaseGame {
|
|||||||
|
|
||||||
_answeredUsers.add(userId);
|
_answeredUsers.add(userId);
|
||||||
|
|
||||||
// 정답 체크 (결과는 바로 반영하되, 탈락 통보는 결과 화면 때 보냄)
|
|
||||||
final currentAnswer = _questions[_currentQuestionIndex]['a'];
|
final currentAnswer = _questions[_currentQuestionIndex]['a'];
|
||||||
bool isCorrect = (answer == currentAnswer);
|
bool isCorrect = (answer == currentAnswer);
|
||||||
|
|
||||||
if (!isCorrect) {
|
if (!isCorrect) {
|
||||||
_aliveUsers.remove(userId); // 명단에서는 제거하되, 통보는 나중에
|
_aliveUsers.remove(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 제출 현황 전파
|
// 제출 현황 전파
|
||||||
@ -143,37 +129,33 @@ class QuizGame extends BaseGame {
|
|||||||
'type': 'PLAYER_STATUS_UPDATE',
|
'type': 'PLAYER_STATUS_UPDATE',
|
||||||
'userId': userId,
|
'userId': userId,
|
||||||
'isSubmitted': true,
|
'isSubmitted': true,
|
||||||
'isAlive': true // 아직은 살아있는 척 (결과 화면에서 공개)
|
'isAlive': isCorrect
|
||||||
});
|
});
|
||||||
|
|
||||||
// [핵심 변경] 전원 제출 완료 시 -> 결과 발표 화면으로 이동
|
// [자동 진행] 전원 제출 시 -> 결과 발표 -> 카운트다운 -> 다음 문제
|
||||||
// (방금 죽은 사람 포함해서 이번 라운드 시작 인원만큼 답변이 왔는지 체크)
|
int currentAliveCount = _aliveUsers.length + (isCorrect ? 0 : 1);
|
||||||
int currentRoundPlayers = _aliveUsers.length + (isCorrect ? 0 : 1); // 방금 뺀 사람 포함
|
if (_answeredUsers.length >= currentAliveCount) {
|
||||||
// 더 정확히는: answeredUsers가 이번 라운드 참가자 수에 도달하면 진행
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
// (여기선 간단히 answeredUsers가 더이상 늘어날 수 없을 때로 판단)
|
_showRoundResultAndNext(); // 결과 발표 및 다음 단계
|
||||||
|
});
|
||||||
// 타임아웃 로직이 없으므로, 현재 살아있는 사람들이 다 냈으면 진행
|
}
|
||||||
// (로직이 복잡해질 수 있으므로, 간단히 '살아있는 사람 수 == 답변 수'가 아니라
|
|
||||||
// '이번 라운드 시작 시점의 생존자 수'를 별도 변수로 관리하는 게 정석이지만,
|
|
||||||
// 여기서는 생존자 수 + 이번에 틀린 사람 수로 계산)
|
|
||||||
|
|
||||||
// 간단 로직: 1초 뒤 체크해서 더 낼 사람이 없으면 진행 (혹은 모두 냈으면 바로)
|
|
||||||
Future.delayed(const Duration(milliseconds: 500), () {
|
|
||||||
// 대충 모두 냈다고 판단되면 (추가 보정 필요할 수 있음)
|
|
||||||
if (_answeredUsers.length >= (_aliveUsers.length + (isCorrect?0:1))) {
|
|
||||||
_showRoundResult();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. [Common] 중간 결과 발표 (NEW)
|
// 3. [Common] 중간 결과 발표 (정답 공개)
|
||||||
if (payload['type'] == 'ROUND_RESULT') {
|
if (payload['type'] == 'ROUND_RESULT') {
|
||||||
_isCountingDown = false;
|
_isCountingDown = false;
|
||||||
final bool isSurvived = payload['survivors'].contains(NetworkManager().me.id);
|
_isShowingResult = true; // 결과 화면 모드 진입
|
||||||
|
_currentCorrectAnswer = payload['correctAnswer'];
|
||||||
|
|
||||||
// 내 생존 여부 업데이트
|
final List<dynamic> survivors = payload['survivors'] ?? [];
|
||||||
|
final bool isSurvived = survivors.contains(NetworkManager().me.id);
|
||||||
|
|
||||||
|
// 내 생존 여부 업데이트 및 효과음
|
||||||
if (!isSurvived && _myStatus == PlayerStatus.alive) {
|
if (!isSurvived && _myStatus == PlayerStatus.alive) {
|
||||||
_handleElimination();
|
_handleElimination();
|
||||||
|
} else if (isSurvived && _myStatus == PlayerStatus.alive) {
|
||||||
|
// 정답 소리 (선택 사항)
|
||||||
|
// SoundManager().playSfx(SoundKey.correct);
|
||||||
}
|
}
|
||||||
|
|
||||||
_gameStateController.add(payload);
|
_gameStateController.add(payload);
|
||||||
@ -183,17 +165,31 @@ class QuizGame extends BaseGame {
|
|||||||
if (payload['type'] == 'PLAYER_STATUS_UPDATE') {
|
if (payload['type'] == 'PLAYER_STATUS_UPDATE') {
|
||||||
final userId = payload['userId'];
|
final userId = payload['userId'];
|
||||||
_answeredUsers.add(userId);
|
_answeredUsers.add(userId);
|
||||||
|
if (payload['isAlive'] == false) {
|
||||||
|
_aliveUsers.remove(userId);
|
||||||
|
}
|
||||||
_gameStateController.add(payload);
|
_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') {
|
if (payload['type'] == 'GAME_STATE_UPDATE' && payload['status'] == 'QUESTION') {
|
||||||
_isCountingDown = false;
|
_isCountingDown = false;
|
||||||
|
_isShowingResult = false;
|
||||||
_resetLocalState();
|
_resetLocalState();
|
||||||
_gameStateController.add(payload);
|
_gameStateController.add(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. [Common] 종료
|
// 7. [Common] 종료
|
||||||
if (payload['type'] == 'GAME_OVER' || payload['type'] == 'GAME_EXIT') {
|
if (payload['type'] == 'GAME_OVER' || payload['type'] == 'GAME_EXIT') {
|
||||||
if (payload['type'] == 'GAME_OVER') {
|
if (payload['type'] == 'GAME_OVER') {
|
||||||
final winnerId = payload['winnerId'];
|
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 currentQ = _questions[_currentQuestionIndex];
|
||||||
|
|
||||||
final resultData = {
|
final resultData = {
|
||||||
'type': 'ROUND_RESULT',
|
'type': 'ROUND_RESULT',
|
||||||
'status': 'RESULT',
|
'status': 'RESULT',
|
||||||
'correctAnswer': currentQ['a'],
|
'correctAnswer': currentQ['a'],
|
||||||
'survivors': _aliveUsers.toList(), // 생존자 명단 전송
|
'survivors': _aliveUsers.toList(),
|
||||||
};
|
};
|
||||||
|
|
||||||
_broadcastState(resultData);
|
_broadcastState(resultData);
|
||||||
|
|
||||||
// 3초 뒤 다음 문제로 자동 이동
|
// 3초간 결과 보여주고 -> 카운트다운 시작
|
||||||
Future.delayed(const Duration(seconds: 3), () {
|
Future.delayed(const Duration(seconds: 3), () {
|
||||||
_nextQuestion();
|
_checkWinnerAndNext();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _nextQuestion() {
|
// 2. 승패 체크 후 -> 카운트다운 -> 문제 출제
|
||||||
// 승패 판정
|
void _checkWinnerAndNext() {
|
||||||
int totalStartPlayers = NetworkManager().guestList.length + 1;
|
int totalStartPlayers = NetworkManager().guestList.length + 1;
|
||||||
|
|
||||||
|
// 종료 조건
|
||||||
if ((totalStartPlayers > 1 && _aliveUsers.length <= 1) || _currentQuestionIndex >= _questions.length - 1) {
|
if ((totalStartPlayers > 1 && _aliveUsers.length <= 1) || _currentQuestionIndex >= _questions.length - 1) {
|
||||||
String? winnerId;
|
String? winnerId;
|
||||||
if (_aliveUsers.isNotEmpty) winnerId = _aliveUsers.first;
|
if (_aliveUsers.isNotEmpty) winnerId = _aliveUsers.first;
|
||||||
@ -241,6 +239,27 @@ class QuizGame extends BaseGame {
|
|||||||
return;
|
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++;
|
_currentQuestionIndex++;
|
||||||
final questionData = _questions[_currentQuestionIndex];
|
final questionData = _questions[_currentQuestionIndex];
|
||||||
|
|
||||||
@ -285,21 +304,22 @@ class QuizGame extends BaseGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
// [UI] Unified View
|
// [UI] Unified View (통일된 UI)
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
@override
|
@override
|
||||||
Widget buildHostView(BuildContext context) => _buildGameScreen(context, isHost: true);
|
Widget buildHostView(BuildContext context) => _buildSharedScreen(context, isHost: true);
|
||||||
|
|
||||||
@override
|
@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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text("OX 서바이벌"),
|
title: const Text("OX 서바이벌", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
centerTitle: true, // 타이틀 중앙 정렬 통일
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
actions: [
|
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<Map<String, dynamic>>(
|
body: StreamBuilder<Map<String, dynamic>>(
|
||||||
@ -310,79 +330,56 @@ class QuizGame extends BaseGame {
|
|||||||
|
|
||||||
final data = snapshot.data!;
|
final data = snapshot.data!;
|
||||||
|
|
||||||
if (data['type'] == 'GAME_COUNTDOWN') {
|
// 1. 종료 화면
|
||||||
int count = data['count'] ?? 3;
|
if (data['type'] == 'GAME_OVER') return _buildResultScreen(context, data['winnerName']);
|
||||||
return Center(child: Text("$count", style: const TextStyle(fontSize: 120, fontWeight: FontWeight.bold, color: Colors.blueAccent)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data['type'] == 'GAME_EXIT') {
|
if (data['type'] == 'GAME_EXIT') {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) { if(context.mounted) Navigator.pop(context); });
|
WidgetsBinding.instance.addPostFrameCallback((_) { if(context.mounted) Navigator.pop(context); });
|
||||||
return const Center(child: Text("종료 중..."));
|
return const Center(child: Text("종료되었습니다."));
|
||||||
}
|
|
||||||
if (data['type'] == 'GAME_OVER') {
|
|
||||||
return _buildResultScreen(context, data['winnerName']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// [NEW] 중간 결과 화면
|
// 2. 카운트다운 화면 (문제 직전)
|
||||||
if (data['status'] == 'RESULT') {
|
// 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);
|
return _buildRoundResultScreen(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 문제 풀이 화면
|
// 4. 문제 풀이 화면
|
||||||
if (data['status'] == 'QUESTION' || _currentQuestionIndex >= 0) {
|
if (data['status'] == 'QUESTION' || _currentQuestionIndex >= 0) {
|
||||||
Map<String, dynamic> qData = data['data'] ?? _questions[_currentQuestionIndex];
|
Map<String, dynamic> qData = data['data'] ?? _questions[_currentQuestionIndex];
|
||||||
|
// 인원 수 계산
|
||||||
int answered = data['answeredCount'] ?? _answeredUsers.length;
|
int answered = data['answeredCount'] ?? _answeredUsers.length;
|
||||||
// 총인원 계산 (생존자 기준이 아님, 이번 라운드 참여자 기준이어야 함. 여기선 간단히 전체 인원 사용)
|
int total = isHost ? _aliveUsers.length : (data['totalAlive'] ?? _aliveUsers.length);
|
||||||
int total = NetworkManager().guestList.length + 1;
|
if (total == 0) total = 1; // div by zero 방지
|
||||||
|
|
||||||
return _buildPlayArea(context, qData, answered, total);
|
return _buildPlayArea(context, qData, answered, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildWaitingScreen("대기 중...");
|
return _buildWaitingScreen("잠시만 기다려주세요...");
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// [NEW] 중간 결과 화면 위젯
|
// ------------------------------------------------------------------------
|
||||||
Widget _buildRoundResultScreen(Map<String, dynamic> data) {
|
// UI Components
|
||||||
final String correctAnswer = data['correctAnswer'];
|
// ------------------------------------------------------------------------
|
||||||
final List<dynamic> 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])),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPlayArea(BuildContext context, Map<String, dynamic> qData, int answered, int total) {
|
Widget _buildPlayArea(BuildContext context, Map<String, dynamic> qData, int answered, int total) {
|
||||||
|
// 탈락자 뷰
|
||||||
if (_myStatus == PlayerStatus.dead) {
|
if (_myStatus == PlayerStatus.dead) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -392,20 +389,33 @@ class QuizGame extends BaseGame {
|
|||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
const Text("탈락했습니다 👻", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
const Text("탈락했습니다 👻", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 20),
|
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),
|
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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// 상단 현황판
|
// 상단 현황판
|
||||||
_PlayerStatusGrid(aliveUsers: _aliveUsers, answeredUsers: _answeredUsers),
|
_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<Color>(Colors.orange),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 문제 텍스트
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 4,
|
flex: 4,
|
||||||
child: Center(
|
child: Center(
|
||||||
@ -419,6 +429,8 @@ class QuizGame extends BaseGame {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 컨트롤 (버튼)
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 3,
|
flex: 3,
|
||||||
child: _isLockedIn
|
child: _isLockedIn
|
||||||
@ -431,11 +443,13 @@ class QuizGame extends BaseGame {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 하단 안내
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 60,
|
height: 60,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: _mySelectedAnswer != null && !_isLockedIn
|
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(),
|
: const SizedBox(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -443,8 +457,53 @@ class QuizGame extends BaseGame {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... (이하 _buildLockedUI, _selectAnswer 등 기존 함수들 유지) ...
|
// [결과 발표 화면]
|
||||||
|
Widget _buildRoundResultScreen(Map<String, dynamic> data) {
|
||||||
|
final String correctAnswer = data['correctAnswer'] ?? "?";
|
||||||
|
final List<dynamic> 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() {
|
Widget _buildLockedUI() {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -456,163 +515,59 @@ class QuizGame extends BaseGame {
|
|||||||
color: _mySelectedAnswer == "O" ? Colors.blue : Colors.red,
|
color: _mySelectedAnswer == "O" ? Colors.blue : Colors.red,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
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) {
|
void _selectAnswer(String answer) {
|
||||||
_lockInTimer?.cancel();
|
_lockInTimer?.cancel();
|
||||||
_mySelectedAnswer = answer;
|
_mySelectedAnswer = answer;
|
||||||
SoundManager().playSfx(SoundKey.click);
|
SoundManager().playSfx(SoundKey.click);
|
||||||
_updateLocalState({'type': 'UI_REFRESH'});
|
_gameStateController.add({'type': 'UI_REFRESH'});
|
||||||
|
_lockInTimer = Timer(const Duration(seconds: 3), () { _submitFinalAnswer(); });
|
||||||
_lockInTimer = Timer(const Duration(seconds: 3), () {
|
|
||||||
_submitFinalAnswer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _submitFinalAnswer() {
|
void _submitFinalAnswer() {
|
||||||
if (_mySelectedAnswer == null) return;
|
if (_mySelectedAnswer == null) return;
|
||||||
_isLockedIn = true;
|
_isLockedIn = true;
|
||||||
_updateLocalState({'type': 'UI_REFRESH'});
|
_gameStateController.add({'type': 'UI_REFRESH'});
|
||||||
|
final payload = {'type': 'ANSWER_SUBMIT', 'answer': _mySelectedAnswer, 'userId': NetworkManager().me.id};
|
||||||
final payload = {
|
if (NetworkManager().role == NetworkRole.host) { onMessageReceived("", payload); } else { NetworkManager().sendMessage(payload); }
|
||||||
'type': 'ANSWER_SUBMIT',
|
|
||||||
'answer': _mySelectedAnswer,
|
|
||||||
'userId': NetworkManager().me.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (NetworkManager().role == NetworkRole.host) {
|
|
||||||
onMessageReceived("", payload);
|
|
||||||
} else {
|
|
||||||
NetworkManager().sendMessage(payload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateLocalState(Map<String, dynamic> data) {
|
|
||||||
_gameStateController.add(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildResultScreen(BuildContext context, String winnerName) {
|
Widget _buildResultScreen(BuildContext context, String winnerName) {
|
||||||
bool amIWinner = _myStatus == PlayerStatus.winner;
|
bool amIWinner = _myStatus == PlayerStatus.winner;
|
||||||
return Center(
|
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("로비로 돌아가기"))]));
|
||||||
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)]));
|
Widget _buildWaitingScreen(String msg) => Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [const CircularProgressIndicator(), SizedBox(height: 20), Text(msg)]));
|
||||||
|
|
||||||
void _confirmExit(BuildContext context) {
|
void _confirmExit(BuildContext context) {
|
||||||
showDialog(context: context, builder: (ctx) => AlertDialog(
|
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)))]));
|
||||||
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 {
|
class _PlayerStatusGrid extends StatelessWidget {
|
||||||
final Set<String> aliveUsers;
|
final Set<String> aliveUsers;
|
||||||
final Set<String> answeredUsers;
|
final Set<String> answeredUsers;
|
||||||
|
|
||||||
const _PlayerStatusGrid({required this.aliveUsers, required this.answeredUsers});
|
const _PlayerStatusGrid({required this.aliveUsers, required this.answeredUsers});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final allUsers = [NetworkManager().me, ...NetworkManager().guestList];
|
final allUsers = [NetworkManager().me, ...NetworkManager().guestList];
|
||||||
|
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))])); }));
|
||||||
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)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [버튼]
|
||||||
class _AnswerBtn extends StatelessWidget {
|
class _AnswerBtn extends StatelessWidget {
|
||||||
final String text;
|
final String text; final Color color; final bool isSelected; final VoidCallback onTap;
|
||||||
final Color color;
|
|
||||||
final bool isSelected;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
const _AnswerBtn({required this.text, required this.color, required this.isSelected, required this.onTap});
|
const _AnswerBtn({required this.text, required this.color, required this.isSelected, required this.onTap});
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
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)))));
|
||||||
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))),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user