This commit is contained in:
2025-11-25 16:34:13 +09:00
parent 92a4525091
commit bc57468aaa
29 changed files with 2206 additions and 641 deletions
@@ -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,
+192 -62
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}");
}
}
@@ -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,
);
}
}
@@ -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);
}
}
@@ -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;
}
}