...
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'; // 추가
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../model/user_info.dart'; // Core 내부 참조
|
||||
|
||||
class AvatarWidget extends StatelessWidget {
|
||||
final UserInfo? user; // UserInfo 객체가 있을 때
|
||||
final String? base64Image; // 객체 없이 이미지 데이터만 있을 때 (설정 화면 등)
|
||||
final int colorValue; // 기본 색상
|
||||
final String nickname; // 기본 닉네임
|
||||
final double size;
|
||||
|
||||
const AvatarWidget({
|
||||
super.key,
|
||||
this.user,
|
||||
this.base64Image,
|
||||
this.colorValue = 0xFF2196F3,
|
||||
this.nickname = "?",
|
||||
this.size = 50,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 1. 우선순위: UserInfo > 직접 입력된 값
|
||||
String? img = user?.profileImageBase64 ?? base64Image;
|
||||
int color = user?.colorValue ?? colorValue;
|
||||
String name = user?.nickname ?? nickname;
|
||||
if (name.isEmpty) name = "?";
|
||||
|
||||
ImageProvider? imageProvider;
|
||||
|
||||
// 2. Base64 이미지 디코딩
|
||||
if (img != null && img.isNotEmpty) {
|
||||
try {
|
||||
Uint8List bytes = base64Decode(img);
|
||||
imageProvider = MemoryImage(bytes);
|
||||
} catch (e) {
|
||||
debugPrint("Avatar decode error: $e");
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: imageProvider != null ? null : Color(color),
|
||||
image: imageProvider != null
|
||||
? DecorationImage(image: imageProvider, fit: BoxFit.cover)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: imageProvider == null
|
||||
? Center(
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0] : "?",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: size * 0.5
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import '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")),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../manager/voice_manager.dart';
|
||||
|
||||
class VoiceWidget extends StatefulWidget {
|
||||
final bool isListening;
|
||||
|
||||
const VoiceWidget({super.key, required this.isListening});
|
||||
|
||||
@override
|
||||
State<VoiceWidget> createState() => _VoiceWidgetState();
|
||||
}
|
||||
|
||||
class _VoiceWidgetState extends State<VoiceWidget> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
String _liveText = "말씀하세요...";
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1000)
|
||||
)..repeat(reverse: true);
|
||||
|
||||
// 실시간 인식 내용 구독
|
||||
VoiceManager().resultStream.listen((text) {
|
||||
if (mounted) {
|
||||
setState(() => _liveText = text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.isListening) return const SizedBox();
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 100), // 하단에서 좀 띄움
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 마이크 아이콘 애니메이션
|
||||
FadeTransition(
|
||||
opacity: _controller,
|
||||
child: const Icon(Icons.mic, color: Colors.redAccent, size: 40),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// 인식된 텍스트
|
||||
Text(
|
||||
_liveText,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,13 @@ dependencies:
|
||||
sqlite3_flutter_libs: ^0.5.0
|
||||
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
|
||||
Reference in New Issue
Block a user