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
@@ -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,
);
}
}
+241 -72
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")),
);
}
}
}
}
@@ -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),
),
],
),
),
);
}
}