This commit is contained in:
lunaticbum 2025-11-25 16:34:13 +09:00
parent 92a4525091
commit bc57468aaa
29 changed files with 2206 additions and 641 deletions

View 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.

View File

@ -0,0 +1 @@
{"version":2,"entries":[{"package":"playWith","rootUri":"../","packageUri":"lib/"}]}

View File

@ -12,6 +12,7 @@ android {
// [수정할 부분]
compileOptions {
isCoreLibraryDesugaringEnabled = true
// 기존 1.8 -> 17로 변경
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@ -46,6 +47,12 @@ android {
}
}
dependencies {
// [추가] 디슈가링 라이브러리 추가 (필수)
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
}
flutter {
source = "../.."
}

View File

@ -15,6 +15,17 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<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
android:label="playwith_app"
android:name="${applicationName}"

View File

@ -5,6 +5,9 @@ PODS:
- bonsoir_darwin (0.0.1):
- Flutter
- FlutterMacOS
- CwlCatchException (2.2.1):
- CwlCatchExceptionSupport (~> 2.2.1)
- CwlCatchExceptionSupport (2.2.1)
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
@ -40,6 +43,11 @@ PODS:
- DKImagePickerController/PhotoGallery
- Flutter
- Flutter (1.0.0)
- flutter_local_notifications (0.0.1):
- Flutter
- gal (1.0.0):
- Flutter
- FlutterMacOS
- GoogleDataTransport (9.4.1):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30911.0, >= 2.30908.0)
@ -102,6 +110,13 @@ PODS:
- SDWebImage (5.21.1):
- SDWebImage/Core (= 5.21.1)
- SDWebImage/Core (5.21.1)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- speech_to_text (7.2.0):
- CwlCatchException
- Flutter
- FlutterMacOS
- sqlite3 (3.50.4):
- sqlite3/common (= 3.50.4)
- sqlite3/common (3.50.4)
@ -134,14 +149,20 @@ DEPENDENCIES:
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- gal (from `.symlinks/plugins/gal/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
- objective_c (from `.symlinks/plugins/objective_c/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
SPEC REPOS:
trunk:
- CwlCatchException
- CwlCatchExceptionSupport
- DKImagePickerController
- DKPhotoGallery
- GoogleDataTransport
@ -169,6 +190,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
gal:
:path: ".symlinks/plugins/gal/darwin"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
mobile_scanner:
@ -177,16 +202,24 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/objective_c/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
speech_to_text:
:path: ".symlinks/plugins/speech_to_text/darwin"
sqlite3_flutter_libs:
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
SPEC CHECKSUMS:
audioplayers_darwin: 4027b33a8f471d996c13f71cb77f0b1583b5d923
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
CwlCatchException: 7acc161b299a6de7f0a46a6ed741eae2c8b4d75a
CwlCatchExceptionSupport: 54ccab8d8c78907b57f99717fb19d4cc3bce02dc
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleMLKit: 97ac7af399057e99182ee8edfa8249e3226a4065
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
@ -204,6 +237,8 @@ SPEC CHECKSUMS:
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SDWebImage: f29024626962457f3470184232766516dee8dfea
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
speech_to_text: 87bf9298952e8d9073be1b6aade6d5758db5170c
sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b
sqlite3_flutter_libs: 86f82662868ee26ff3451f73cac9c5fc2a1f57fa
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4

View File

@ -56,12 +56,16 @@
<key>NSCameraUsageDescription</key>
<string>QR 코드를 스캔하여 방에 접속하기 위해 카메라 권한이 필요합니다.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>채팅방에 사진을 공유하기 위해 갤러리 접근 권한이 필요합니다.</string>
<string>채팅방에 사진을 공유하기 위해 갤러리 접근 권한이 필요합니다.</string>
<key>NSCameraUsageDescription</key>
<string>사진을 찍어 공유하기 위해 카메라 권한이 필요합니다.</string>
<string>사진을 찍어 공유하기 위해 카메라 권한이 필요합니다.</string>
<key>NSMicrophoneUsageDescription</key>
<string>동영상 촬영을 위해 마이크 권한이 필요합니다.</string>
<string>동영상 촬영을 위해 마이크 권한이 필요합니다.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>이미지를 갤러리에 저장하기 위해 권한이 필요합니다.</string>
<key>NSMicrophoneUsageDescription</key>
<string>정답을 음성으로 말하기 위해 마이크 권한이 필요합니다.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>말한 내용을 텍스트로 변환하여 정답을 확인합니다.</string>
</dict>
</plist>

View 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);
},
),
),
);
}
}

View 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;
}
}

View File

@ -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('입장하기'),
),
],
),
),
),
);
}
}

View 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)),
),
],
),
),
),
);
}
}

View File

@ -1,21 +1,23 @@
import 'package:flutter/material.dart';
import 'package:playwith_core/playwith_core.dart';
import 'package:playwith_game_quiz/quiz_game.dart'; // import
import 'intro_screen.dart';
import 'package:playwith_game_quiz/quiz_game.dart';
import 'login_screen.dart'; // [] import ( )
import 'intro/intro_screen.dart'; // intro
import 'lobby_screen.dart';
void main() {
// 1.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// 2. ( !)
// AssetSource는 'assets/' .
SoundManager().initialize(soundPaths: {
SoundKey.bgm: 'audio/bgm.mp3',
SoundKey.correct: 'audio/correct.mp3',
SoundKey.wrong: 'audio/wrong.mp3',
SoundKey.win: 'audio/win.mp3',
SoundKey.click: 'audio/correct.mp3',
});
await NotificationManager().initialize();
runApp(const PlayWithApp());
}
@ -28,43 +30,53 @@ class PlayWithApp extends StatefulWidget {
class _PlayWithAppState extends State<PlayWithApp> {
final _net = NetworkManager();
final _settings = SettingsNotifier();
//
final List<BaseGame> _games = [
QuizGame(), // !
QuizGame(),
];
@override
void initState() {
super.initState();
// [ ] 'GAME_START'
_net.messageStream.listen((data) {
if (data['type'] == 'GAME_START') {
final String gameId = data['gameId'];
// ID에
final game = _games.firstWhere(
(g) => g.id == gameId,
orElse: () => throw Exception("Game not found: $gameId")
);
// ( , )
// GlobalKey<NavigatorState> , context를 .
// MVP에서는 LobbyScreen .
}
// ...
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'PlayWith',
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const IntroScreen(),
return ListenableBuilder(
listenable: _settings,
builder: (context, child) {
return MaterialApp(
title: 'PlayWith',
theme: _settings.currentTheme,
themeMode: _settings.currentThemeMode,
builder: (context, child) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(_settings.fontScale),
),
child: child!,
);
},
// [ ] IntroScreen을
// nextScreenBuilder를 (Lobby)
home: IntroScreen(
nextScreenBuilder: (context) => const LoginScreen(),
),
);
},
);
}
}
}
// [] (IntroScreen.dart의 )
// (IntroScreen) ,
// 'LoginScreen' 'NameInputScreen' .
// 'IntroScreen' ,
// 'SplashAnimationScreen' .
// 'IntroAnimationScreen' ,
// -> ( IntroScreen) -> .

View 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)),
);
}
}

View File

@ -7,14 +7,24 @@ import Foundation
import audioplayers_darwin
import bonsoir_darwin
import file_picker
import file_selector_macos
import flutter_local_notifications
import gal
import mobile_scanner
import shared_preferences_foundation
import speech_to_text
import sqlite3_flutter_libs
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
}

View File

@ -229,10 +229,10 @@ packages:
dependency: transitive
description:
name: file_picker
sha256: "1bbf65dd997458a08b531042ec3794112a6c39c07c37ff22113d2e7e4f81d4e4"
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
url: "https://pub.dev"
source: hosted
version: "6.2.1"
version: "8.3.7"
file_selector_linux:
dependency: transitive
description:
@ -286,6 +286,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_local_notifications:
dependency: transitive
description:
name: flutter_local_notifications
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
url: "https://pub.dev"
source: hosted
version: "17.2.4"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
url: "https://pub.dev"
source: hosted
version: "4.0.1"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@ -304,6 +328,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
gal:
dependency: transitive
description:
name: gal
sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
http:
dependency: transitive
description:
@ -384,6 +416,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
leak_tracker:
dependency: transitive
description:
@ -520,6 +560,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
pedantic:
dependency: transitive
description:
name: pedantic
sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
permission_handler:
dependency: "direct main"
description:
@ -630,6 +678,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.0"
shared_preferences:
dependency: transitive
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b"
url: "https://pub.dev"
source: hosted
version: "2.4.17"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
@ -643,6 +747,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.1"
speech_to_text:
dependency: transitive
description:
name: speech_to_text
sha256: c07557664974afa061f221d0d4186935bea4220728ea9446702825e8b988db04
url: "https://pub.dev"
source: hosted
version: "7.3.0"
speech_to_text_platform_interface:
dependency: transitive
description:
name: speech_to_text_platform_interface
sha256: a1935847704e41ee468aad83181ddd2423d0833abe55d769c59afca07adb5114
url: "https://pub.dev"
source: hosted
version: "2.3.0"
speech_to_text_windows:
dependency: transitive
description:
name: speech_to_text_windows
sha256: "2c9846d18253c7bbe059a276297ef9f27e8a2745dead32192525beb208195072"
url: "https://pub.dev"
source: hosted
version: "1.0.0+beta.8"
sqlite3:
dependency: transitive
description:
@ -707,6 +835,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.6"
timezone:
dependency: transitive
description:
name: timezone
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
typed_data:
dependency: transitive
description:

View File

@ -9,7 +9,9 @@
#include <audioplayers_windows/audioplayers_windows_plugin.h>
#include <bonsoir_windows/bonsoir_windows_plugin_c_api.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 <speech_to_text_windows/speech_to_text_windows.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
@ -19,8 +21,12 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
GalPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GalPluginCApi"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
SpeechToTextWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SpeechToTextWindows"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
}

View File

@ -6,7 +6,9 @@ list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
bonsoir_windows
file_selector_windows
gal
permission_handler_windows
speech_to_text_windows
sqlite3_flutter_libs
)

View File

@ -1,14 +1,22 @@
import 'dart:async';
import 'package:flutter/widgets.dart'; // AppLifecycleState
import '../network/network_manager.dart';
import '../model/play_packet.dart';
import 'notification_manager.dart'; // [New]
class ChatMessage {
final String senderId; // []
final String senderName;
final String text;
final bool isMe;
final DateTime timestamp;
ChatMessage(this.senderName, this.text, this.isMe) : timestamp = DateTime.now();
ChatMessage({
required this.senderId, //
required this.senderName,
required this.text,
required this.isMe,
}) : timestamp = DateTime.now();
}
class GlobalChatManager {
@ -16,13 +24,11 @@ class GlobalChatManager {
factory GlobalChatManager() => _instance;
GlobalChatManager._internal();
// UI가
final _messageController = StreamController<List<ChatMessage>>.broadcast();
Stream<List<ChatMessage>> get messageStream => _messageController.stream;
final List<ChatMessage> _messages = [];
/// [NetworkManager]
void onPacketReceived(PlayPacket packet) {
if (packet.type != PacketType.chat) return;
@ -31,25 +37,50 @@ class GlobalChatManager {
final text = data['text'];
final isMe = packet.senderId == NetworkManager().me.id;
final chatMsg = ChatMessage(senderName, text, isMe);
final chatMsg = ChatMessage(
senderId: packet.senderId, // ID
senderName: senderName,
text: text,
isMe: isMe,
);
_messages.add(chatMsg);
// UI
_messageController.add(List.from(_messages));
if (!isMe) {
_checkBackgroundAndNotify(senderName, text);
}
}
// [New]
void _checkBackgroundAndNotify(String sender, String text) {
// WidgetsBinding을
final state = WidgetsBinding.instance.lifecycleState;
// (paused), (inactive)
if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive || state == AppLifecycleState.detached) {
NotificationManager().showNotification(
id: DateTime.now().millisecondsSinceEpoch % 10000, // ID
title: sender,
body: text,
);
}
}
///
void sendMessage(String text) {
if (text.trim().isEmpty) return;
final myInfo = NetworkManager().me;
// 1. ( )
final myMsg = ChatMessage(myInfo.nickname, text, true);
final myMsg = ChatMessage(
senderId: myInfo.id, // ID
senderName: myInfo.nickname,
text: text,
isMe: true,
);
_messages.add(myMsg);
_messageController.add(List.from(_messages));
// 2. (PlayPacket )
final packet = PlayPacket(
type: PacketType.chat,
senderId: myInfo.id,

View File

@ -11,6 +11,29 @@ import '../database/ephemeral_database.dart';
import '../network/network_manager.dart';
import '../model/play_packet.dart';
class _TransferState {
final String mediaId;
final String fileName;
final String senderId;
final String senderName;
final String type;
final int totalChunks;
final File tempFile;
final IOSink fileSink;
int receivedChunks = 0;
_TransferState({
required this.mediaId,
required this.fileName,
required this.senderId,
required this.senderName,
required this.type,
required this.totalChunks,
required this.tempFile,
required this.fileSink,
});
}
class MediaManager {
static final MediaManager _instance = MediaManager._internal();
factory MediaManager() => _instance;
@ -19,44 +42,54 @@ class MediaManager {
EphemeralDatabase? _db;
String? _currentRoomId;
// UI에서 (DB )
final Map<String, _TransferState> _activeTransfers = {};
Completer<void>? _ackCompleter;
// [] 16KB
static const int CHUNK_SIZE = 16 * 1024;
Stream<List<MediaItem>> get galleryStream {
if (_db == null) return const Stream.empty();
return _db!.select(_db!.mediaItems).watch(); // watch()
return _db!.select(_db!.mediaItems).watch();
}
// ---------------------------------------------------------------------------
// [1] (Lifecycle)
//
// ---------------------------------------------------------------------------
/// / (DB )
Future<void> initialize(String roomId) async {
// DB가
await cleanup();
_currentRoomId = roomId;
_db = await EphemeralDatabase.create(roomId);
print("[MediaManager] DB Initialized for Room: $roomId");
final tempDir = await getTemporaryDirectory();
final roomDir = Directory('${tempDir.path}/rooms/$roomId');
if (!await roomDir.exists()) {
await roomDir.create(recursive: true);
}
print("[MediaManager] Initialized. Storage: ${roomDir.path}");
}
/// ( )
Future<void> cleanup() async {
if (_db != null) {
await _db!.close();
_db = null;
}
// DB ( )
for (var state in _activeTransfers.values) {
await state.fileSink.close();
}
_activeTransfers.clear();
_ackCompleter = null;
if (_currentRoomId != null) {
try {
final dbFolder = await getApplicationDocumentsDirectory();
// EphemeralDatabase.create에서
final file = File('${dbFolder.path}/room_$_currentRoomId.sqlite');
if (await file.exists()) {
await file.delete();
print("[MediaManager] DB File Deleted 🔥");
}
final dbFile = File('${dbFolder.path}/room_$_currentRoomId.sqlite');
if (await dbFile.exists()) await dbFile.delete();
final tempDir = await getTemporaryDirectory();
final roomDir = Directory('${tempDir.path}/rooms/$_currentRoomId');
if (await roomDir.exists()) await roomDir.delete(recursive: true);
print("[MediaManager] Cleaned up 🔥");
} catch (e) {
print("[MediaManager] Cleanup Error: $e");
}
@ -65,18 +98,23 @@ class MediaManager {
}
// ---------------------------------------------------------------------------
// [2] (Send)
// (Stop-and-Wait ARQ)
// ---------------------------------------------------------------------------
Future<void> sendMedia({
required String filePath,
required String type, // 'IMAGE', 'AUDIO'
required String type,
}) async {
if (_db == null) return;
if (_db == null || _currentRoomId == null) return;
final myInfo = NetworkManager().me;
final mediaId = const Uuid().v4();
// A. DB에 ( )
final file = File(filePath);
final fileName = filePath.split('/').last;
final int fileSize = await file.length();
final int totalChunks = (fileSize / CHUNK_SIZE).ceil();
print("[MediaManager] Uploading $fileName ($totalChunks chunks) with ACK...");
await _db!.insertMedia(MediaItemsCompanion(
id: drift.Value(mediaId),
senderId: drift.Value(myInfo.id),
@ -86,64 +124,156 @@ class MediaManager {
createdAt: drift.Value(DateTime.now()),
));
// B. (MVP: Base64)
// : . ( Chunk )
final file = File(filePath);
final fileBytes = await file.readAsBytes();
final base64Data = base64Encode(fileBytes);
final fileName = filePath.split('/').last;
// C.
final packet = PlayPacket(
//
await _sendPacketAndWaitAck(PlayPacket(
type: PacketType.media,
senderId: myInfo.id,
timestamp: DateTime.now().millisecondsSinceEpoch,
payload: {
'id': mediaId,
'step': 'HEADER',
'mediaId': mediaId,
'fileName': fileName,
'senderName': myInfo.nickname,
'type': type,
'data': base64Data,
'fileName': fileName,
'totalChunks': totalChunks,
'fileSize': fileSize,
},
timestamp: DateTime.now().millisecondsSinceEpoch,
);
));
//
final raf = await file.open();
try {
for (int i = 0; i < totalChunks; i++) {
int length = CHUNK_SIZE;
if (i == totalChunks - 1) {
length = fileSize - (i * CHUNK_SIZE);
}
List<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);
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 {
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;
try {
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);
final String mediaId = data['mediaId'];
// B. DB에 -> watch() UI가
await _db!.insertMedia(MediaItemsCompanion(
id: drift.Value(data['id']),
senderId: drift.Value(packet.senderId),
senderName: drift.Value(data['senderName']),
type: drift.Value(data['type']),
filePath: drift.Value(savePath),
createdAt: drift.Value(DateTime.fromMillisecondsSinceEpoch(packet.timestamp)),
));
print("[MediaManager] File Saved: $savePath");
try {
if (step == 'HEADER') {
final String fileName = data['fileName'];
final tempDir = await getTemporaryDirectory();
final savePath = '${tempDir.path}/rooms/$_currentRoomId/${const Uuid().v4()}_$fileName';
final file = File(savePath);
await file.create(recursive: true);
final sink = file.openWrite();
_activeTransfers[mediaId] = _TransferState(
mediaId: mediaId,
fileName: fileName,
senderId: packet.senderId,
senderName: data['senderName'],
type: data['type'],
totalChunks: data['totalChunks'],
tempFile: file,
fileSink: sink,
);
print("[MediaManager] Recv Header. Sending ACK.");
_sendAck(mediaId);
}
else if (step == 'CHUNK') {
final state = _activeTransfers[mediaId];
if (state == null) return;
final String base64Data = data['data'];
final List<int> bytes = base64Decode(base64Data);
state.fileSink.add(bytes);
state.receivedChunks++;
_sendAck(mediaId);
if (state.receivedChunks >= state.totalChunks) {
await _finishTransfer(state);
}
}
} catch (e) {
print("[MediaManager] Receive Error: $e");
}
}
void _sendAck(String mediaId) {
final myInfo = NetworkManager().me;
NetworkManager().sendPacket(PlayPacket(
type: PacketType.media,
senderId: myInfo.id,
timestamp: DateTime.now().millisecondsSinceEpoch,
payload: {
'step': 'ACK',
'mediaId': mediaId,
},
));
}
Future<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}");
}
}

View 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,
);
}
}

View 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);
}
}

View 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;
}
}

View File

@ -5,14 +5,16 @@ class UserInfo extends Equatable {
final String nickname;
final int avatarIndex;
final int colorValue;
final bool isReady; // []
final bool isReady;
final String? profileImageBase64; // [] (Base64)
const UserInfo({
required this.id,
required this.nickname,
this.avatarIndex = 0,
this.colorValue = 0xFF2196F3,
this.isReady = false, // false
this.isReady = false,
this.profileImageBase64, //
});
factory UserInfo.fromJson(Map<String, dynamic> json) {
@ -21,7 +23,8 @@ class UserInfo extends Equatable {
nickname: json['nickname'] as String,
avatarIndex: json['avatarIndex'] as int? ?? 0,
colorValue: json['colorValue'] as int? ?? 0xFF2196F3,
isReady: json['isReady'] as bool? ?? false, // JSON
isReady: json['isReady'] as bool? ?? false,
profileImageBase64: json['profileImageBase64'] as String?, //
);
}
@ -31,7 +34,8 @@ class UserInfo extends Equatable {
'nickname': nickname,
'avatarIndex': avatarIndex,
'colorValue': colorValue,
'isReady': isReady, // JSON
'isReady': isReady,
'profileImageBase64': profileImageBase64, //
};
}
@ -40,7 +44,8 @@ class UserInfo extends Equatable {
String? nickname,
int? avatarIndex,
int? colorValue,
bool? isReady, // copyWith
bool? isReady,
String? profileImageBase64, // copyWith
}) {
return UserInfo(
id: id ?? this.id,
@ -48,9 +53,10 @@ class UserInfo extends Equatable {
avatarIndex: avatarIndex ?? this.avatarIndex,
colorValue: colorValue ?? this.colorValue,
isReady: isReady ?? this.isReady,
profileImageBase64: profileImageBase64 ?? this.profileImageBase64,
);
}
@override
List<Object?> get props => [id, nickname, avatarIndex, colorValue, isReady];
List<Object?> get props => [id, nickname, avatarIndex, colorValue, isReady, profileImageBase64];
}

View File

@ -7,11 +7,11 @@ import 'package:bonsoir/bonsoir.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:uuid/uuid.dart';
import '../manager/notification_manager.dart';
import '../model/user_info.dart';
import '../model/play_packet.dart';
import '../manager/global_chat_manager.dart';
import '../manager/media_manager.dart'; // [New]
import '../manager/media_manager.dart';
enum NetworkRole { none, host, guest }
@ -23,6 +23,16 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this);
}
// ------------------------------------------------------------------------
//
// ------------------------------------------------------------------------
// [] (End Of Packet) - Base64와
static const String PACKET_DELIMITER = "|||EOP|||";
static const int HEARTBEAT_INTERVAL_SEC = 3;
static const int TIMEOUT_SEC = 10;
static const int RECONNECT_WAIT_SEC = 5;
// ------------------------------------------------------------------------
//
// ------------------------------------------------------------------------
@ -35,41 +45,43 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
ServerSocket? _serverSocket;
Socket? _clientSocket;
// 1:1
final Map<Socket, UserInfo?> _connectedGuests = {};
// UI
final List<UserInfo> guestList = [];
// []
final Map<Socket, String> _packetBuffers = {};
BonsoirService? _bonsoirService;
BonsoirBroadcast? _bonsoirBroadcast;
BonsoirDiscovery? _bonsoirDiscovery;
//
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
//
final _logController = StreamController<String>.broadcast();
Stream<String> get logStream => _logController.stream;
// &
Timer? _heartbeatTimer;
Timer? _disconnectWaitTimer;
DateTime? _lastPongTime;
bool _isReconnecting = false;
static const int HEARTBEAT_INTERVAL_SEC = 3;
static const int TIMEOUT_SEC = 10;
static const int RECONNECT_WAIT_SEC = 5;
// ------------------------------------------------------------------------
//
// ------------------------------------------------------------------------
void initialize({required String nickname}) {
void initialize({
required String nickname,
String? profileImage, // []
}) {
final uuid = const Uuid().v4().substring(0, 8);
final randomColor = 0xFF000000 | (nickname.hashCode & 0xFFFFFF);
me = UserInfo(id: uuid, nickname: nickname, colorValue: randomColor);
me = UserInfo(
id: uuid,
nickname: nickname,
colorValue: randomColor,
profileImageBase64: profileImage, // []
);
_log("초기화 완료: ${me.nickname}");
}
@ -161,7 +173,6 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
_bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!);
await _bonsoirBroadcast!.start();
// [NEW] DB (Host)
await MediaManager().initialize(roomName);
_startHeartbeat();
@ -176,6 +187,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
void _handleNewGuest(Socket client) {
_log("🎉 연결됨: ${client.remoteAddress.address}");
_connectedGuests[client] = null;
_packetBuffers[client] = ""; //
client.listen(
(Uint8List data) => _onDataReceived(client, data),
@ -191,6 +203,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
guestList.removeWhere((u) => u.id == user.id);
}
_connectedGuests.remove(client);
_packetBuffers.remove(client);
client.close();
notifyListeners();
}
@ -240,10 +253,11 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
_log("🚀 접속 시도: $ip:$port");
_clientSocket = await Socket.connect(ip, port, timeout: const Duration(seconds: 5));
_log("✅ 접속 성공!");
_packetBuffers[_clientSocket!] = ""; //
sendMessage({'type': 'HANDSHAKE', 'payload': me.toJson()});
// [NEW] DB (Guest는 ID )
await MediaManager().initialize("guest_${ip.replaceAll('.', '_')}");
_lastPongTime = DateTime.now();
@ -265,7 +279,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
}
// ------------------------------------------------------------------------
// & ()
// ( )
// ------------------------------------------------------------------------
void sendPacket(PlayPacket packet) {
sendMessage(packet.toJson());
@ -275,6 +289,7 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
if (role == NetworkRole.guest && _clientSocket == null) return;
final jsonString = jsonEncode(messageMap);
//
if (messageMap['type'] != 'PING' && messageMap['type'] != 'PONG') {
if (messageMap['type'] == 'chat') {
@ -286,7 +301,10 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
}
}
final List<int> data = utf8.encode('$jsonString\n');
// []
final fullMessage = '$jsonString$PACKET_DELIMITER';
final List<int> data = utf8.encode(fullMessage);
if (role == NetworkRole.host) {
for (var socket in _connectedGuests.keys) {
socket.add(data);
@ -297,86 +315,98 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
}
void _onDataReceived(Socket socket, Uint8List data) {
final String rawString = utf8.decode(data);
final List<String> splitMessages = rawString.split('\n');
for (var msg in splitMessages) {
if (msg.trim().isEmpty) continue;
try {
final Map<String, dynamic> jsonMap = jsonDecode(msg);
try {
// 1.
String buffer = _packetBuffers[socket] ?? "";
// 2.
buffer += utf8.decode(data, allowMalformed: true);
// 1. (Ping/Pong)
if (jsonMap['type'] == 'PING') {
sendMessage({'type': 'PONG'});
_lastPongTime = DateTime.now();
return;
}
if (jsonMap['type'] == 'PONG') {
_lastPongTime = DateTime.now();
return;
}
// 3. (PACKET_DELIMITER)
while (buffer.contains(PACKET_DELIMITER)) {
final int delimiterIndex = buffer.indexOf(PACKET_DELIMITER);
// 2.
if (jsonMap['type'] == 'HANDSHAKE') {
final guestInfo = UserInfo.fromJson(jsonMap['payload']);
_connectedGuests[socket] = guestInfo;
guestList.removeWhere((u) => u.id == guestInfo.id);
guestList.add(guestInfo);
notifyListeners();
_messageController.add(jsonMap);
return;
}
// 3.
if (jsonMap['type'] == 'TOGGLE_READY') {
final String userId = jsonMap['userId'];
final bool isReady = jsonMap['isReady'];
final index = guestList.indexWhere((u) => u.id == userId);
if (index != -1) {
guestList[index] = guestList[index].copyWith(isReady: isReady);
notifyListeners();
}
if (role == NetworkRole.host) {
sendMessage(jsonMap);
_checkAllReadyAndStart();
}
_messageController.add(jsonMap);
return;
}
//
final String msg = buffer.substring(0, delimiterIndex);
// 4.
if (jsonMap['type'] == 'GAME_START') {
_resetAllReadyState();
_messageController.add(jsonMap);
return;
//
buffer = buffer.substring(delimiterIndex + PACKET_DELIMITER.length);
//
if (msg.trim().isNotEmpty) {
_processMessage(socket, msg);
}
// 5. (Chat, Media, Game)
if (jsonMap.containsKey('payload') && jsonMap.containsKey('senderId')) {
final packet = PlayPacket.fromJson(jsonMap);
// [] -> GlobalChatManager
if (packet.type == PacketType.chat) {
GlobalChatManager().onPacketReceived(packet);
return;
}
// [] -> MediaManager
if (packet.type == PacketType.media) {
MediaManager().onMediaReceived(packet);
return;
}
}
// 6.
_messageController.add(jsonMap);
} catch (e) {
_log("파싱 에러: $e");
}
// 4. ( )
_packetBuffers[socket] = buffer;
} catch (e) {
_log("데이터 수신 에러: $e");
}
}
void _processMessage(Socket socket, String msg) {
try {
final Map<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) {
if (role != NetworkRole.guest) return;
_log("⚠️ 연결 끊김: $reason");
_clientSocket?.destroy();
@ -392,13 +423,32 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
if (_disconnectWaitTimer != null && _disconnectWaitTimer!.isActive) return;
// 5
_disconnectWaitTimer = Timer(const Duration(seconds: RECONNECT_WAIT_SEC), () {
_log("💀 복구 실패. 종료.");
// []
_sendDisconnectionNotification();
stopNetwork(force: true);
});
_attemptReconnection();
}
// []
void _sendDisconnectionNotification() {
// ()
final state = WidgetsBinding.instance.lifecycleState;
if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive || state == AppLifecycleState.detached) {
NotificationManager().showNotification(
id: 9999, // ID ( )
title: "연결 끊김 ⚠️",
body: "방과의 연결이 종료되었습니다. 앱을 실행해 확인해주세요.",
);
}
}
Future<void> _attemptReconnection() async {
if (hostIp == null || hostPort == null) return;
_isReconnecting = true;
@ -451,7 +501,6 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
if (!force && _disconnectWaitTimer != null) return;
_log("🛑 종료");
// [NEW] DB
MediaManager().cleanup();
_heartbeatTimer?.cancel();
@ -461,9 +510,12 @@ class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
_bonsoirDiscovery?.stop();
_serverSocket?.close();
_clientSocket?.close();
for (var s in _connectedGuests.keys) s.close();
_connectedGuests.clear();
_packetBuffers.clear(); //
guestList.clear();
role = NetworkRole.none;
_serverSocket = null;
_clientSocket = null;

View File

@ -6,9 +6,13 @@ export 'model/user_info.dart';
export 'model/play_packet.dart';
export 'utils/sound_manager.dart';
export 'manager/global_chat_manager.dart';
export 'widgets/game_chat_overlay.dart';
// [] DB (Drift가 )
export 'manager/media_manager.dart';
export 'manager/settings_manager.dart';
export 'database/ephemeral_database.dart';
// Drift의 (Value ) ()
export 'package:drift/drift.dart' show Value;
// [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'; //

View 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,
);
}
}

View File

@ -1,9 +1,14 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:gal/gal.dart';
import 'package:image_picker/image_picker.dart';
import '../manager/global_chat_manager.dart';
import '../manager/media_manager.dart'; //
import '../database/ephemeral_database.dart'; // DB
import '../manager/media_manager.dart';
import '../database/ephemeral_database.dart';
import '../network/network_manager.dart'; // UserInfo
import '../model/user_info.dart';
import 'avatar_widget.dart'; // AvatarWidget import
class GameChatOverlay extends StatefulWidget {
const GameChatOverlay({super.key});
@ -15,17 +20,88 @@ class GameChatOverlay extends StatefulWidget {
class _GameChatOverlayState extends State<GameChatOverlay> {
final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController();
bool _isExpanded = false;
int _unreadCount = 0;
String _latestPreview = "채팅에 참여해보세요!";
StreamSubscription? _chatSub;
StreamSubscription? _mediaSub;
int _lastChatLength = 0;
int _lastMediaLength = 0;
@override
void initState() {
super.initState();
_chatSub = GlobalChatManager().messageStream.listen((messages) {
if (messages.isEmpty) return;
if (messages.length > _lastChatLength) {
final lastMsg = messages.last;
if (!_isExpanded && mounted) {
setState(() {
_unreadCount++;
_latestPreview = "${lastMsg.senderName}: ${lastMsg.text}";
});
}
}
_lastChatLength = messages.length;
});
_mediaSub = MediaManager().galleryStream.listen((mediaList) {
if (mediaList.isEmpty) return;
if (mediaList.length > _lastMediaLength) {
final lastMedia = mediaList.last;
if (!_isExpanded && mounted) {
setState(() {
_unreadCount++;
_latestPreview = "📷 ${lastMedia.senderName}님이 사진을 보냈습니다.";
});
}
}
_lastMediaLength = mediaList.length;
});
}
@override
void dispose() {
_chatSub?.cancel();
_mediaSub?.cancel();
_textController.dispose();
_scrollController.dispose();
super.dispose();
}
void _toggleExpand() {
setState(() {
_isExpanded = !_isExpanded;
if (_isExpanded) {
_unreadCount = 0;
_latestPreview = "";
}
});
}
@override
Widget build(BuildContext context) {
// [ ]
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
return Align(
alignment: Alignment.bottomCenter,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
// : ( )
height: _isExpanded ? 500 : 60,
margin: const EdgeInsets.all(10),
duration: const Duration(milliseconds: 200), //
height: _isExpanded ? 500 : 60,
// [ ] (10) (bottomPadding)
// .
margin: EdgeInsets.only(
left: 10,
right: 10,
bottom: 10 + bottomPadding,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.85),
borderRadius: BorderRadius.circular(20),
@ -33,59 +109,97 @@ class _GameChatOverlayState extends State<GameChatOverlay> {
),
child: Column(
children: [
// 1. (/)
// 1.
GestureDetector(
onTap: () => setState(() => _isExpanded = !_isExpanded),
onTap: _toggleExpand,
behavior: HitTestBehavior.translucent,
child: Container(
width: double.infinity,
height: 30,
alignment: Alignment.center,
child: Icon(
_isExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up,
color: Colors.white,
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.centerLeft,
child: Row(
children: [
Icon(
_isExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up,
color: Colors.white70,
),
const SizedBox(width: 10),
if (!_isExpanded)
Expanded(
child: Text(
_latestPreview,
style: const TextStyle(color: Colors.white, fontSize: 14),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
)
else
const Text("채팅 및 미디어", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)),
if (!_isExpanded && _unreadCount > 0)
Container(
margin: const EdgeInsets.only(left: 10),
padding: const EdgeInsets.all(6),
decoration: const BoxDecoration(color: Colors.redAccent, shape: BoxShape.circle),
child: Text("$_unreadCount", style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
),
],
),
),
),
//
// 2.
if (_isExpanded) ...[
const Divider(height: 1, color: Colors.white24),
// 2. ( )
// DB의 (Stream)
SizedBox(
height: 100,
//
Container(
height: 110,
width: double.infinity,
color: Colors.black12,
child: StreamBuilder<List<MediaItem>>(
stream: MediaManager().galleryStream,
initialData: const [],
builder: (context, snapshot) {
final mediaList = snapshot.data ?? [];
if (snapshot.hasError) return const Center(child: Icon(Icons.error, color: Colors.grey));
final mediaList = snapshot.data ?? [];
if (mediaList.isEmpty) {
return const Center(child: Text("공유된 미디어가 없습니다.", style: TextStyle(color: Colors.white54, fontSize: 12)));
return const Center(child: Text("공유된 사진이 없습니다.", style: TextStyle(color: Colors.white38, fontSize: 12)));
}
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 10),
padding: const EdgeInsets.all(10),
itemCount: mediaList.length,
itemBuilder: (context, index) {
final item = mediaList[index];
return Padding(
padding: const EdgeInsets.only(right: 8.0),
padding: const EdgeInsets.only(right: 10),
child: GestureDetector(
onTap: () => _showFullImage(context, item),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(item.filePath),
width: 100,
height: 100,
fit: BoxFit.cover,
errorBuilder: (_,__,___) => Container(
width: 100, height: 100, color: Colors.grey,
child: const Icon(Icons.broken_image),
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(item.filePath),
width: 70, height: 70,
fit: BoxFit.cover,
errorBuilder: (_,__,___) => Container(
width: 70, height: 70, color: Colors.grey[800],
child: const Icon(Icons.broken_image, color: Colors.white54),
),
),
),
),
const SizedBox(height: 4),
Text(
item.senderName.length > 4 ? "${item.senderName.substring(0,4)}.." : item.senderName,
style: const TextStyle(color: Colors.white70, fontSize: 10),
)
],
),
),
);
@ -95,33 +209,68 @@ class _GameChatOverlayState extends State<GameChatOverlay> {
),
),
const Divider(color: Colors.white24),
const Divider(height: 1, color: Colors.white24),
// 3.
//
// 3.
Expanded(
child: StreamBuilder<List<ChatMessage>>(
stream: GlobalChatManager().messageStream,
builder: (context, snapshot) {
final messages = snapshot.data ?? [];
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
});
// ... ( ) ...
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 10),
padding: const EdgeInsets.all(10),
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[index];
// [] ( )
UserInfo? senderInfo;
if (msg.isMe) {
senderInfo = NetworkManager().me;
} else {
// ( null일 )
try {
senderInfo = NetworkManager().guestList.firstWhere((u) => u.id == msg.senderId);
} catch (_) {}
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
"${msg.senderName}: ${msg.text}",
style: TextStyle(
color: msg.isMe ? Colors.yellow : Colors.white,
fontSize: 14,
),
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: msg.isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!msg.isMe) ...[
// [] AvatarWidget
AvatarWidget(
user: senderInfo, //
nickname: msg.senderName, //
size: 30, //
),
const SizedBox(width: 8),
],
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: msg.isMe ? Colors.blueAccent : Colors.white10,
borderRadius: BorderRadius.circular(15),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!msg.isMe)
Text(msg.senderName, style: const TextStyle(fontSize: 10, color: Colors.grey)),
Text(msg.text, style: const TextStyle(color: Colors.white)),
],
),
),
),
],
),
);
},
@ -130,12 +279,12 @@ class _GameChatOverlayState extends State<GameChatOverlay> {
),
),
// 4. (+ )
Padding(
//
Container(
padding: const EdgeInsets.all(8.0),
color: Colors.black54,
child: Row(
children: [
// []
IconButton(
icon: const Icon(Icons.add_photo_alternate, color: Colors.blueAccent),
onPressed: _pickAndSendImage,
@ -145,10 +294,10 @@ class _GameChatOverlayState extends State<GameChatOverlay> {
controller: _textController,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
hintText: "채팅 입력...",
hintText: "메시지 보내기...",
hintStyle: TextStyle(color: Colors.white54),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 10),
),
onSubmitted: _sendMessage,
),
@ -173,26 +322,19 @@ class _GameChatOverlayState extends State<GameChatOverlay> {
_textController.clear();
}
// [ ]
Future<void> _pickAndSendImage() async {
final picker = ImagePicker();
// ( )
final XFile? image = await picker.pickImage(
source: ImageSource.gallery,
imageQuality: 50, //
maxWidth: 800,
imageQuality: 70,
maxWidth: 1024,
);
if (image != null) {
// MediaManager를
await MediaManager().sendMedia(
filePath: image.path,
type: 'IMAGE',
);
await MediaManager().sendMedia(filePath: image.path, type: 'IMAGE');
}
}
// [ ]
void _showFullImage(BuildContext context, MediaItem item) {
showDialog(
context: context,
@ -202,23 +344,33 @@ class _GameChatOverlayState extends State<GameChatOverlay> {
child: Stack(
alignment: Alignment.center,
children: [
InteractiveViewer(
child: Image.file(File(item.filePath)),
),
InteractiveViewer(child: Image.file(File(item.filePath))),
Positioned(
top: 40,
right: 20,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white, size: 30),
onPressed: () => Navigator.pop(ctx),
top: 40, left: 20, right: 20,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.close, color: Colors.white, size: 30),
onPressed: () => Navigator.pop(ctx),
),
IconButton(
icon: const Icon(Icons.download, color: Colors.white, size: 30),
tooltip: "갤러리에 저장",
onPressed: () => _saveImageToGallery(context, item.filePath),
),
],
),
),
Positioned(
bottom: 20,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
color: Colors.black54,
child: Text("보낸 사람: ${item.senderName}", style: const TextStyle(color: Colors.white)),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(5)),
child: Text("From: ${item.senderName}", style: const TextStyle(color: Colors.white)),
),
)
],
@ -226,4 +378,21 @@ class _GameChatOverlayState extends State<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")),
);
}
}
}
}

View 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),
),
],
),
),
);
}
}

View File

@ -18,11 +18,13 @@ dependencies:
sqlite3_flutter_libs: ^0.5.0
path_provider: ^2.1.1
path: ^1.8.3
gal: ^2.3.0 # [추가] 갤러리 저장용
# [파일 피커]
image_picker: ^1.0.4
file_picker: ^6.1.1
image_picker: ^1.1.2
file_picker: ^8.1.4
shared_preferences: ^2.2.2
speech_to_text: ^7.0.0
flutter_local_notifications: ^17.0.0
dev_dependencies:
drift_dev: ^2.13.0
build_runner: ^2.4.6

View File

@ -12,32 +12,33 @@ class QuizGame extends BaseGame {
String get name => "OX 퀴즈 서바이벌";
@override
String get description => "방장도 플레이어! 3초 안에 선택하세요.";
String get description => "끝까지 살아남으세요!";
// ------------------------------------------------------------------------
//
// ------------------------------------------------------------------------
final _gameStateController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get gameStateStream => _gameStateController.stream;
StreamSubscription? _networkSubscription;
// UI
Map<String, dynamic>? _lastState;
//
final Set<String> _aliveUsers = {}; // ID
final Set<String> _answeredUsers = {}; // ID
final Set<String> _aliveUsers = {};
final Set<String> _answeredUsers = {};
//
PlayerStatus _myStatus = PlayerStatus.alive;
String? _mySelectedAnswer;
bool _isLockedIn = false;
Timer? _lockInTimer;
//
// [] ?
bool _isCountingDown = false;
int _countdownValue = 3;
// [] ? ( )
bool _isShowingResult = false;
String _currentCorrectAnswer = "";
final List<Map<String, dynamic>> _questions = [
{"q": "사과는 영어로 Apple이다.", "a": "O"},
{"q": "바나나는 길어지면 기차다.", "a": "X"},
@ -54,25 +55,23 @@ class QuizGame extends BaseGame {
// ------------------------------------------------------------------------
@override
void onStart() {
super.onStart();
print("Quiz Game Started!");
_resetLocalState();
_lastState = null;
_aliveUsers.clear();
// ( + )
_aliveUsers.add(NetworkManager().me.id);
for (var guest in NetworkManager().guestList) {
_aliveUsers.add(guest.id);
}
_networkSubscription = NetworkManager().messageStream.listen((payload) {
onMessageReceived("", payload);
});
// [Host]
// [Host] 퀀
if (NetworkManager().role == NetworkRole.host) {
Future.delayed(const Duration(milliseconds: 1000), () {
_startCountdown();
//
Future.delayed(const Duration(seconds: 1), () {
_startNextQuestionSequence();
});
}
}
@ -80,24 +79,8 @@ class QuizGame extends BaseGame {
@override
void onDispose() {
_lockInTimer?.cancel();
_networkSubscription?.cancel();
_gameStateController.close();
}
// [Host]
void _startCountdown() {
if (_currentQuestionIndex != -1) return;
Timer.periodic(const Duration(seconds: 1), (timer) {
int nextCount = 3 - timer.tick;
if (nextCount > 0) {
_broadcastState({'type': 'GAME_COUNTDOWN', 'count': nextCount});
} else {
timer.cancel();
_nextQuestion(); //
}
});
_broadcastState({'type': 'GAME_COUNTDOWN', 'count': 3});
super.onDispose();
}
// ------------------------------------------------------------------------
@ -110,11 +93,15 @@ class QuizGame extends BaseGame {
_lastState = payload;
}
// 1. [Common]
// 1. [Common]
if (payload['type'] == 'GAME_COUNTDOWN') {
_isShowingResult = false; //
_isCountingDown = true;
_countdownValue = payload['count'];
// 3, 2, 1
SoundManager().playSfx(SoundKey.click);
_gameStateController.add(payload);
}
@ -130,12 +117,11 @@ class QuizGame extends BaseGame {
_answeredUsers.add(userId);
// ( , )
final currentAnswer = _questions[_currentQuestionIndex]['a'];
bool isCorrect = (answer == currentAnswer);
if (!isCorrect) {
_aliveUsers.remove(userId); // ,
_aliveUsers.remove(userId);
}
//
@ -143,37 +129,33 @@ class QuizGame extends BaseGame {
'type': 'PLAYER_STATUS_UPDATE',
'userId': userId,
'isSubmitted': true,
'isAlive': true // ( )
'isAlive': isCorrect
});
// [ ] ->
// ( )
int currentRoundPlayers = _aliveUsers.length + (isCorrect ? 0 : 1); //
// : answeredUsers가
// ( answeredUsers가 )
// ,
// ( , '살아있는 사람 수 == 답변 수'
// '이번 라운드 시작 시점의 생존자 수' ,
// + )
// : 1 ( )
Future.delayed(const Duration(milliseconds: 500), () {
// ( )
if (_answeredUsers.length >= (_aliveUsers.length + (isCorrect?0:1))) {
_showRoundResult();
}
});
// [ ] -> -> ->
int currentAliveCount = _aliveUsers.length + (isCorrect ? 0 : 1);
if (_answeredUsers.length >= currentAliveCount) {
Future.delayed(const Duration(milliseconds: 500), () {
_showRoundResultAndNext(); //
});
}
}
// 3. [Common] (NEW)
// 3. [Common] ( )
if (payload['type'] == 'ROUND_RESULT') {
_isCountingDown = false;
final bool isSurvived = payload['survivors'].contains(NetworkManager().me.id);
_isShowingResult = true; //
_currentCorrectAnswer = payload['correctAnswer'];
//
final List<dynamic> survivors = payload['survivors'] ?? [];
final bool isSurvived = survivors.contains(NetworkManager().me.id);
//
if (!isSurvived && _myStatus == PlayerStatus.alive) {
_handleElimination();
} else if (isSurvived && _myStatus == PlayerStatus.alive) {
// ( )
// SoundManager().playSfx(SoundKey.correct);
}
_gameStateController.add(payload);
@ -183,17 +165,31 @@ class QuizGame extends BaseGame {
if (payload['type'] == 'PLAYER_STATUS_UPDATE') {
final userId = payload['userId'];
_answeredUsers.add(userId);
if (payload['isAlive'] == false) {
_aliveUsers.remove(userId);
}
_gameStateController.add(payload);
}
// 5. [Common]
// 5. [Common] ()
if (payload['type'] == 'PLAYER_ELIMINATED') {
final targetId = payload['targetUserId'];
_aliveUsers.remove(targetId);
if (targetId == NetworkManager().me.id) {
_handleElimination();
}
_gameStateController.add({'type': 'UI_REFRESH'});
}
// 6. [Common]
if (payload['type'] == 'GAME_STATE_UPDATE' && payload['status'] == 'QUESTION') {
_isCountingDown = false;
_isShowingResult = false;
_resetLocalState();
_gameStateController.add(payload);
}
// 6. [Common]
// 7. [Common]
if (payload['type'] == 'GAME_OVER' || payload['type'] == 'GAME_EXIT') {
if (payload['type'] == 'GAME_OVER') {
final winnerId = payload['winnerId'];
@ -210,30 +206,32 @@ class QuizGame extends BaseGame {
}
// ------------------------------------------------------------------------
// [Host Logic]
// [Host Logic]
// ------------------------------------------------------------------------
// [NEW]
void _showRoundResult() {
// 1. ( O/X )
void _showRoundResultAndNext() {
final currentQ = _questions[_currentQuestionIndex];
final resultData = {
'type': 'ROUND_RESULT',
'status': 'RESULT',
'correctAnswer': currentQ['a'],
'survivors': _aliveUsers.toList(), //
'survivors': _aliveUsers.toList(),
};
_broadcastState(resultData);
// 3
// 3 ->
Future.delayed(const Duration(seconds: 3), () {
_nextQuestion();
_checkWinnerAndNext();
});
}
void _nextQuestion() {
//
// 2. -> ->
void _checkWinnerAndNext() {
int totalStartPlayers = NetworkManager().guestList.length + 1;
//
if ((totalStartPlayers > 1 && _aliveUsers.length <= 1) || _currentQuestionIndex >= _questions.length - 1) {
String? winnerId;
if (_aliveUsers.isNotEmpty) winnerId = _aliveUsers.first;
@ -241,6 +239,27 @@ class QuizGame extends BaseGame {
return;
}
// 퀀
_startNextQuestionSequence();
}
// 3. (3->2->1)
void _startNextQuestionSequence() {
int count = 3;
// 1
Timer.periodic(const Duration(seconds: 1), (timer) {
_broadcastState({'type': 'GAME_COUNTDOWN', 'count': count});
if (count == 0) {
timer.cancel();
_sendNewQuestion(); //
}
count--;
});
}
// 4.
void _sendNewQuestion() {
_currentQuestionIndex++;
final questionData = _questions[_currentQuestionIndex];
@ -285,21 +304,22 @@ class QuizGame extends BaseGame {
}
// ------------------------------------------------------------------------
// [UI] Unified View
// [UI] Unified View ( UI)
// ------------------------------------------------------------------------
@override
Widget buildHostView(BuildContext context) => _buildGameScreen(context, isHost: true);
Widget buildHostView(BuildContext context) => _buildSharedScreen(context, isHost: true);
@override
Widget buildGuestView(BuildContext context) => _buildGameScreen(context, isHost: false);
Widget buildGuestView(BuildContext context) => _buildSharedScreen(context, isHost: false);
Widget _buildGameScreen(BuildContext context, {required bool isHost}) {
Widget _buildSharedScreen(BuildContext context, {required bool isHost}) {
return Scaffold(
appBar: AppBar(
title: const Text("OX 서바이벌"),
title: const Text("OX 서바이벌", style: TextStyle(fontWeight: FontWeight.bold)),
centerTitle: true, //
automaticallyImplyLeading: false,
actions: [
if (isHost) IconButton(icon: const Icon(Icons.power_settings_new), onPressed: () => _confirmExit(context))
if (isHost) IconButton(icon: const Icon(Icons.close), onPressed: () => _confirmExit(context))
],
),
body: StreamBuilder<Map<String, dynamic>>(
@ -310,79 +330,56 @@ class QuizGame extends BaseGame {
final data = snapshot.data!;
if (data['type'] == 'GAME_COUNTDOWN') {
int count = data['count'] ?? 3;
return Center(child: Text("$count", style: const TextStyle(fontSize: 120, fontWeight: FontWeight.bold, color: Colors.blueAccent)));
}
// 1.
if (data['type'] == 'GAME_OVER') return _buildResultScreen(context, data['winnerName']);
if (data['type'] == 'GAME_EXIT') {
WidgetsBinding.instance.addPostFrameCallback((_) { if(context.mounted) Navigator.pop(context); });
return const Center(child: Text("종료 중..."));
}
if (data['type'] == 'GAME_OVER') {
return _buildResultScreen(context, data['winnerName']);
return const Center(child: Text("종료되었습니다."));
}
// [NEW]
if (data['status'] == 'RESULT') {
// 2. ( )
// count가 0
if (_isCountingDown) {
int count = data['count'] ?? 3;
// 0 'Start!'
String text = count > 0 ? "$count" : "GO!";
return Center(
child: Text(
text,
style: TextStyle(fontSize: 120, fontWeight: FontWeight.bold, color: Theme.of(context).primaryColor)
)
);
}
// 3. ( )
if (_isShowingResult || data['status'] == 'RESULT') {
return _buildRoundResultScreen(data);
}
//
// 4.
if (data['status'] == 'QUESTION' || _currentQuestionIndex >= 0) {
Map<String, dynamic> qData = data['data'] ?? _questions[_currentQuestionIndex];
//
int answered = data['answeredCount'] ?? _answeredUsers.length;
// ( , . )
int total = NetworkManager().guestList.length + 1;
int total = isHost ? _aliveUsers.length : (data['totalAlive'] ?? _aliveUsers.length);
if (total == 0) total = 1; // div by zero
return _buildPlayArea(context, qData, answered, total);
}
return _buildWaitingScreen("대기 중...");
return _buildWaitingScreen("잠시만 기다려주세요...");
},
),
);
}
// [NEW]
Widget _buildRoundResultScreen(Map<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),
//
Container(
width: 150, height: 150,
decoration: BoxDecoration(
color: correctAnswer == "O" ? Colors.blue : Colors.red,
shape: BoxShape.circle,
),
child: Center(child: Text(correctAnswer, style: const TextStyle(fontSize: 80, color: Colors.white, fontWeight: FontWeight.bold))),
),
const SizedBox(height: 40),
//
if (_myStatus == PlayerStatus.dead)
const Text("이미 탈락하셨습니다. 👻", style: TextStyle(fontSize: 20, color: Colors.grey))
else if (amISurvived)
const Text("생존! 다음 라운드 진출! 🎉", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green))
else
const Text("탈락했습니다... 😭", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.red)),
const SizedBox(height: 20),
Text("잠시 후 다음 문제가 시작됩니다.", style: TextStyle(color: Colors.grey[600])),
],
),
);
}
// ------------------------------------------------------------------------
// UI Components
// ------------------------------------------------------------------------
// [ ]
Widget _buildPlayArea(BuildContext context, Map<String, dynamic> qData, int answered, int total) {
//
if (_myStatus == PlayerStatus.dead) {
return Center(
child: Column(
@ -392,20 +389,33 @@ class QuizGame extends BaseGame {
const SizedBox(height: 20),
const Text("탈락했습니다 👻", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 20),
Text("관전 중... ($answered 제출)", style: const TextStyle(fontSize: 18, color: Colors.grey)),
Text("관전 중... ($answered / $total 제출)", style: const TextStyle(fontSize: 18, color: Colors.grey)),
const SizedBox(height: 40),
Text("문제: ${qData['q']}", style: const TextStyle(color: Colors.grey)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 30),
child: Text("문제: ${qData['q']}", textAlign: TextAlign.center, style: const TextStyle(color: Colors.grey)),
),
],
),
);
}
//
return Column(
children: [
//
_PlayerStatusGrid(aliveUsers: _aliveUsers, answeredUsers: _answeredUsers),
const Divider(),
const Divider(height: 1),
//
LinearProgressIndicator(
value: total > 0 ? answered / total : 0,
minHeight: 6,
backgroundColor: Colors.grey[200],
valueColor: const AlwaysStoppedAnimation<Color>(Colors.orange),
),
//
Expanded(
flex: 4,
child: Center(
@ -419,6 +429,8 @@ class QuizGame extends BaseGame {
),
),
),
// ()
Expanded(
flex: 3,
child: _isLockedIn
@ -431,11 +443,13 @@ class QuizGame extends BaseGame {
],
),
),
//
SizedBox(
height: 60,
child: Center(
child: _mySelectedAnswer != null && !_isLockedIn
? const Text("3초 후 확정됩니다! (변경 가능)", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold))
? const Text("3초 후 확정됩니다!", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold))
: const SizedBox(),
),
),
@ -443,8 +457,53 @@ class QuizGame extends BaseGame {
);
}
// ... ( _buildLockedUI, _selectAnswer ) ...
// [ ]
Widget _buildRoundResultScreen(Map<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() {
return Center(
child: Column(
@ -456,163 +515,59 @@ class QuizGame extends BaseGame {
color: _mySelectedAnswer == "O" ? Colors.blue : Colors.red,
),
const SizedBox(height: 20),
const Text("제출 완료! 결과를 기다리는 중...", style: TextStyle(fontSize: 20, color: Colors.grey)),
const Text("제출 완료!\n결과를 기다리는 중...", textAlign: TextAlign.center, style: TextStyle(fontSize: 18, color: Colors.grey)),
],
),
);
}
// ... ( _selectAnswer, _submitFinalAnswer, _buildResultScreen, _buildWaitingScreen, _confirmExit ) ...
void _selectAnswer(String answer) {
_lockInTimer?.cancel();
_mySelectedAnswer = answer;
SoundManager().playSfx(SoundKey.click);
_updateLocalState({'type': 'UI_REFRESH'});
_lockInTimer = Timer(const Duration(seconds: 3), () {
_submitFinalAnswer();
});
_gameStateController.add({'type': 'UI_REFRESH'});
_lockInTimer = Timer(const Duration(seconds: 3), () { _submitFinalAnswer(); });
}
void _submitFinalAnswer() {
if (_mySelectedAnswer == null) return;
_isLockedIn = true;
_updateLocalState({'type': 'UI_REFRESH'});
final payload = {
'type': 'ANSWER_SUBMIT',
'answer': _mySelectedAnswer,
'userId': NetworkManager().me.id,
};
if (NetworkManager().role == NetworkRole.host) {
onMessageReceived("", payload);
} else {
NetworkManager().sendMessage(payload);
}
_gameStateController.add({'type': 'UI_REFRESH'});
final payload = {'type': 'ANSWER_SUBMIT', 'answer': _mySelectedAnswer, 'userId': NetworkManager().me.id};
if (NetworkManager().role == NetworkRole.host) { onMessageReceived("", payload); } else { NetworkManager().sendMessage(payload); }
}
void _updateLocalState(Map<String, dynamic> data) {
_gameStateController.add(data);
}
Widget _buildResultScreen(BuildContext context, String winnerName) {
bool amIWinner = _myStatus == PlayerStatus.winner;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(amIWinner ? Icons.emoji_events : Icons.thumb_down, size: 100, color: amIWinner ? Colors.amber : Colors.grey),
const SizedBox(height: 20),
Text(amIWinner ? "우승!" : "게임 종료", style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold)),
const SizedBox(height: 10),
Text("최종 우승자: $winnerName", style: const TextStyle(fontSize: 20)),
const SizedBox(height: 50),
ElevatedButton(onPressed: () { onDispose(); Navigator.pop(context); }, child: const Text("나가기")),
],
),
);
return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(amIWinner ? Icons.emoji_events : Icons.thumb_down, size: 100, color: amIWinner ? Colors.amber : Colors.grey), const SizedBox(height: 20), Text(amIWinner ? "우승!" : "게임 종료", style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold)), const SizedBox(height: 10), Text("최종 우승자: $winnerName", style: const TextStyle(fontSize: 20)), const SizedBox(height: 50), ElevatedButton(onPressed: () { onDispose(); Navigator.pop(context); }, child: const Text("로비로 돌아가기"))]));
}
Widget _buildWaitingScreen(String msg) => Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [const CircularProgressIndicator(), SizedBox(height: 20), Text(msg)]));
void _confirmExit(BuildContext context) {
showDialog(context: context, builder: (ctx) => AlertDialog(
title: const Text("게임 종료"), content: const Text("방을 폭파하시겠습니까?"),
actions: [
TextButton(onPressed: ()=>Navigator.pop(ctx), child: const Text("취소")),
TextButton(onPressed: () {
Navigator.pop(ctx);
NetworkManager().sendMessage({'type': 'GAME_EXIT'});
onDispose();
Navigator.pop(context);
}, child: const Text("종료", style: TextStyle(color: Colors.red))),
]
));
showDialog(context: context, builder: (ctx) => AlertDialog(title: const Text("게임 종료"), content: const Text("방을 폭파하시겠습니까?"), actions: [TextButton(onPressed: ()=>Navigator.pop(ctx), child: const Text("취소")), TextButton(onPressed: () { Navigator.pop(ctx); NetworkManager().sendMessage({'type': 'GAME_EXIT'}); onDispose(); Navigator.pop(context); }, child: const Text("종료", style: TextStyle(color: Colors.red)))]));
}
}
// ------------------------------------------------------------------------
// [Widget] ( )
// ------------------------------------------------------------------------
// []
class _PlayerStatusGrid extends StatelessWidget {
final Set<String> aliveUsers;
final Set<String> answeredUsers;
const _PlayerStatusGrid({required this.aliveUsers, required this.answeredUsers});
@override
Widget build(BuildContext context) {
final allUsers = [NetworkManager().me, ...NetworkManager().guestList];
return Container(
height: 90,
width: double.infinity,
padding: const EdgeInsets.all(10),
color: Colors.grey[100],
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: allUsers.length,
itemBuilder: (context, index) {
final user = allUsers[index];
final isAlive = aliveUsers.contains(user.id);
final isSubmitted = answeredUsers.contains(user.id);
final isMe = user.id == NetworkManager().me.id;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Column(
children: [
Stack(
children: [
Container(
width: 50, height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isAlive ? Color(user.colorValue) : Colors.grey,
border: isSubmitted ? Border.all(color: Colors.green, width: 3) : null,
),
child: Center(
child: isAlive
? Text(user.nickname[0], style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold))
: const Icon(Icons.close, color: Colors.white),
),
),
if (isMe) Positioned(top:0, right:0, child: Container(width: 10, height: 10, decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle))),
],
),
const SizedBox(height: 4),
Text(user.nickname, style: TextStyle(fontSize: 10, color: isAlive ? Colors.black : Colors.grey)),
],
),
);
},
),
);
return Container(height: 80, width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 10), color: Colors.grey[50], child: ListView.builder(scrollDirection: Axis.horizontal, itemCount: allUsers.length, itemBuilder: (context, index) { final user = allUsers[index]; final isAlive = aliveUsers.contains(user.id); final isSubmitted = answeredUsers.contains(user.id); return Padding(padding: const EdgeInsets.symmetric(horizontal: 6.0), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Stack(children: [Container(width: 40, height: 40, decoration: BoxDecoration(shape: BoxShape.circle, color: isAlive ? Color(user.colorValue) : Colors.grey, border: isSubmitted ? Border.all(color: Colors.green, width: 3) : null), child: AvatarWidget(user: user, size: 40)), if (!isAlive) Positioned.fill(child: Container(decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle), child: const Icon(Icons.close, size: 20, color: Colors.white)))]), const SizedBox(height: 4), Text(user.nickname, style: TextStyle(fontSize: 10, color: isAlive ? Colors.black : Colors.grey))])); }));
}
}
// []
class _AnswerBtn extends StatelessWidget {
final String text;
final Color color;
final bool isSelected;
final VoidCallback onTap;
final String text; final Color color; final bool isSelected; final VoidCallback onTap;
const _AnswerBtn({required this.text, required this.color, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: isSelected ? 140 : 120,
height: isSelected ? 140 : 120,
decoration: BoxDecoration(
color: color.withOpacity(isSelected ? 1.0 : 0.6),
shape: BoxShape.circle,
border: isSelected ? Border.all(color: Colors.white, width: 5) : null,
boxShadow: [BoxShadow(color: color.withOpacity(0.4), blurRadius: 10, offset: const Offset(0, 6))]
),
child: Center(child: Text(text, style: const TextStyle(fontSize: 60, color: Colors.white, fontWeight: FontWeight.bold))),
),
);
return GestureDetector(onTap: onTap, child: AnimatedContainer(duration: const Duration(milliseconds: 200), width: isSelected ? 140 : 120, height: isSelected ? 140 : 120, decoration: BoxDecoration(color: color.withOpacity(isSelected ? 1.0 : 0.6), shape: BoxShape.circle, border: isSelected ? Border.all(color: Colors.white, width: 5) : null, boxShadow: [BoxShadow(color: color.withOpacity(0.4), blurRadius: 10, offset: const Offset(0, 6))]), child: Center(child: Text(text, style: const TextStyle(fontSize: 60, color: Colors.white, fontWeight: FontWeight.bold)))));
}
}