...
This commit is contained in:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user