...
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
|
||||
class AdBannerWidget extends StatefulWidget {
|
||||
const AdBannerWidget({super.key});
|
||||
|
||||
@override
|
||||
State<AdBannerWidget> createState() => _AdBannerWidgetState();
|
||||
}
|
||||
|
||||
class _AdBannerWidgetState extends State<AdBannerWidget> {
|
||||
BannerAd? _bannerAd;
|
||||
bool _isLoaded = false;
|
||||
|
||||
// 테스트용 광고 ID (실제 출시 전에는 본인의 광고 ID로 교체해야 합니다)
|
||||
final String _adUnitId = Platform.isAndroid
|
||||
? 'ca-app-pub-3940256099942544/6300978111' // 안드로이드 테스트 ID
|
||||
: 'ca-app-pub-3940256099942544/2934735716'; // iOS 테스트 ID
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAd();
|
||||
}
|
||||
|
||||
void _loadAd() {
|
||||
_bannerAd = BannerAd(
|
||||
adUnitId: _adUnitId,
|
||||
request: const AdRequest(),
|
||||
size: AdSize.banner,
|
||||
listener: BannerAdListener(
|
||||
onAdLoaded: (ad) {
|
||||
debugPrint('$ad loaded.');
|
||||
setState(() {
|
||||
_isLoaded = true;
|
||||
});
|
||||
},
|
||||
onAdFailedToLoad: (ad, err) {
|
||||
debugPrint('BannerAd failed to load: $err');
|
||||
ad.dispose();
|
||||
},
|
||||
),
|
||||
)..load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bannerAd?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_bannerAd != null && _isLoaded) {
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
width: _bannerAd!.size.width.toDouble(),
|
||||
height: _bannerAd!.size.height.toDouble(),
|
||||
child: AdWidget(ad: _bannerAd!),
|
||||
);
|
||||
}
|
||||
// 광고가 로드되지 않았을 때 공간을 차지하지 않거나 대체 위젯 표시
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
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';
|
||||
import '../network/network_manager.dart'; // UserInfo 조회를 위해 추가
|
||||
import '../network/network_manager.dart';
|
||||
import '../model/user_info.dart';
|
||||
import 'avatar_widget.dart'; // AvatarWidget import
|
||||
import 'avatar_widget.dart';
|
||||
|
||||
class GameChatOverlay extends StatefulWidget {
|
||||
const GameChatOverlay({super.key});
|
||||
final double bottomOffset; // 초기 위치 설정을 위한 하단 여백
|
||||
|
||||
const GameChatOverlay({
|
||||
super.key,
|
||||
this.bottomOffset = 0.0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GameChatOverlay> createState() => _GameChatOverlayState();
|
||||
@@ -20,21 +26,28 @@ class GameChatOverlay extends StatefulWidget {
|
||||
class _GameChatOverlayState extends State<GameChatOverlay> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
bool _isExpanded = false;
|
||||
|
||||
|
||||
bool _isExpanded = false; // 채팅창 열림 여부
|
||||
Offset _position = Offset.zero; // 현재 위치
|
||||
bool _isInitialized = false; // 초기 위치 설정 여부
|
||||
|
||||
int _unreadCount = 0;
|
||||
String _latestPreview = "채팅에 참여해보세요!";
|
||||
|
||||
String _latestPreview = "";
|
||||
|
||||
StreamSubscription? _chatSub;
|
||||
StreamSubscription? _mediaSub;
|
||||
int _lastChatLength = 0;
|
||||
int _lastMediaLength = 0;
|
||||
|
||||
// 창 크기 설정
|
||||
final double _fabSize = 60.0;
|
||||
final double _windowWidth = 320.0;
|
||||
final double _windowHeight = 450.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
|
||||
_chatSub = GlobalChatManager().messageStream.listen((messages) {
|
||||
if (messages.isEmpty) return;
|
||||
if (messages.length > _lastChatLength) {
|
||||
@@ -56,7 +69,7 @@ class _GameChatOverlayState extends State<GameChatOverlay> {
|
||||
if (!_isExpanded && mounted) {
|
||||
setState(() {
|
||||
_unreadCount++;
|
||||
_latestPreview = "📷 ${lastMedia.senderName}님이 사진을 보냈습니다.";
|
||||
_latestPreview = "📷 사진 도착";
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -77,256 +90,299 @@ class _GameChatOverlayState extends State<GameChatOverlay> {
|
||||
setState(() {
|
||||
_isExpanded = !_isExpanded;
|
||||
if (_isExpanded) {
|
||||
_unreadCount = 0;
|
||||
_latestPreview = "";
|
||||
_unreadCount = 0; // 열면 읽음 처리
|
||||
|
||||
// 화면 밖으로 나가지 않도록 위치 보정
|
||||
// (버튼 상태일 때 구석에 있다가 열리면 화면 밖으로 나갈 수 있음)
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
double newX = _position.dx;
|
||||
double newY = _position.dy;
|
||||
|
||||
if (newX + _windowWidth > screenSize.width) {
|
||||
newX = screenSize.width - _windowWidth - 10;
|
||||
}
|
||||
if (newY + _windowHeight > screenSize.height) {
|
||||
newY = screenSize.height - _windowHeight - 80; // 하단 여유
|
||||
}
|
||||
_position = Offset(math.max(10, newX), math.max(40, newY));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// [핵심 수정] 키보드가 올라왔을 때 그 높이만큼 값을 가져옴
|
||||
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 1. 초기 위치 설정 (우측 하단, 광고 위)
|
||||
if (!_isInitialized) {
|
||||
final initialX = constraints.maxWidth - _fabSize - 20;
|
||||
final initialY = constraints.maxHeight - _fabSize - widget.bottomOffset - 20;
|
||||
_position = Offset(initialX, initialY);
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: AnimatedContainer(
|
||||
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),
|
||||
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, spreadRadius: 2)],
|
||||
),
|
||||
child: Column(
|
||||
return Stack(
|
||||
children: [
|
||||
// 1. 상단 핸들
|
||||
GestureDetector(
|
||||
onTap: _toggleExpand,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
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),
|
||||
Positioned(
|
||||
left: _position.dx,
|
||||
top: _position.dy,
|
||||
child: GestureDetector(
|
||||
onPanUpdate: (details) {
|
||||
setState(() {
|
||||
// 2. 드래그 이동 (화면 밖으로 나가지 않게 제한)
|
||||
double newX = _position.dx + details.delta.dx;
|
||||
double newY = _position.dy + details.delta.dy;
|
||||
|
||||
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)),
|
||||
),
|
||||
],
|
||||
final double currentWidth = _isExpanded ? _windowWidth : _fabSize;
|
||||
final double currentHeight = _isExpanded ? _windowHeight : _fabSize;
|
||||
|
||||
newX = newX.clamp(0.0, constraints.maxWidth - currentWidth);
|
||||
newY = newY.clamp(0.0, constraints.maxHeight - currentHeight);
|
||||
|
||||
_position = Offset(newX, newY);
|
||||
});
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(_isExpanded ? 20 : 30),
|
||||
child: _isExpanded ? _buildExpandedView() : _buildCollapsedView(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 내부 콘텐츠
|
||||
if (_isExpanded) ...[
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
|
||||
// 미디어 갤러리
|
||||
Container(
|
||||
height: 110,
|
||||
width: double.infinity,
|
||||
color: Colors.black12,
|
||||
child: StreamBuilder<List<MediaItem>>(
|
||||
stream: MediaManager().galleryStream,
|
||||
initialData: const [],
|
||||
builder: (context, snapshot) {
|
||||
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.white38, fontSize: 12)));
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.all(10),
|
||||
itemCount: mediaList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = mediaList[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showFullImage(context, item),
|
||||
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),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
// [UI] 닫힌 상태 (플로팅 버튼)
|
||||
Widget _buildCollapsedView() {
|
||||
return GestureDetector(
|
||||
onTap: _toggleExpand,
|
||||
child: Container(
|
||||
width: _fabSize,
|
||||
height: _fabSize,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.blueAccent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
const Icon(Icons.chat_bubble_outline, color: Colors.white, size: 28),
|
||||
if (_unreadCount > 0)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.redAccent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
_unreadCount > 9 ? "9+" : "$_unreadCount",
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
|
||||
// 채팅 리스트
|
||||
// 3. 채팅 리스트 부분
|
||||
Expanded(
|
||||
child: StreamBuilder<List<ChatMessage>>(
|
||||
stream: GlobalChatManager().messageStream,
|
||||
builder: (context, snapshot) {
|
||||
final messages = snapshot.data ?? [];
|
||||
// ... (스크롤 로직 동일) ...
|
||||
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
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: 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 입력창
|
||||
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,
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
hintText: "메시지 보내기...",
|
||||
hintStyle: TextStyle(color: Colors.white54),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 10),
|
||||
),
|
||||
onSubmitted: _sendMessage,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send, color: Colors.blue),
|
||||
onPressed: () => _sendMessage(_textController.text),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// [UI] 열린 상태 (채팅창)
|
||||
Widget _buildExpandedView() {
|
||||
return Container(
|
||||
width: _windowWidth,
|
||||
height: _windowHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 헤더 (드래그 핸들)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white10,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Icon(Icons.drag_handle, color: Colors.white54),
|
||||
const Text("채팅", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
GestureDetector(
|
||||
onTap: _toggleExpand,
|
||||
child: const Icon(Icons.close, color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 미디어 갤러리 (있으면 표시)
|
||||
StreamBuilder<List<MediaItem>>(
|
||||
stream: MediaManager().galleryStream,
|
||||
initialData: const [],
|
||||
builder: (context, snapshot) {
|
||||
final mediaList = snapshot.data ?? [];
|
||||
if (mediaList.isEmpty) return const SizedBox();
|
||||
|
||||
return Container(
|
||||
height: 80,
|
||||
color: Colors.black12,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: mediaList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = mediaList[index];
|
||||
return GestureDetector(
|
||||
onTap: () => _showFullImage(context, item),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
File(item.filePath),
|
||||
width: 64, height: 64,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// 채팅 리스트
|
||||
Expanded(
|
||||
child: StreamBuilder<List<ChatMessage>>(
|
||||
stream: GlobalChatManager().messageStream,
|
||||
builder: (context, snapshot) {
|
||||
final messages = snapshot.data ?? [];
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final msg = messages[index];
|
||||
UserInfo? senderInfo;
|
||||
if (!msg.isMe) {
|
||||
try {
|
||||
senderInfo = NetworkManager().guestList.firstWhere((u) => u.id == msg.senderId);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: msg.isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!msg.isMe) ...[
|
||||
AvatarWidget(user: senderInfo, nickname: msg.senderName, size: 28),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: msg.isMe ? Colors.blueAccent : Colors.white12,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 입력창
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_photo_alternate, color: Colors.blueAccent),
|
||||
onPressed: _pickAndSendImage,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
hintText: "메시지...",
|
||||
hintStyle: TextStyle(color: Colors.white38),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 10),
|
||||
),
|
||||
onSubmitted: _sendMessage,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send, color: Colors.blue),
|
||||
onPressed: () => _sendMessage(_textController.text),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendMessage(String text) {
|
||||
if (text.trim().isEmpty) return;
|
||||
GlobalChatManager().sendMessage(text);
|
||||
_textController.clear();
|
||||
// 메시지 전송 후 스크롤 하단으로
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickAndSendImage() async {
|
||||
final picker = ImagePicker();
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
imageQuality: 70,
|
||||
imageQuality: 70,
|
||||
maxWidth: 1024,
|
||||
);
|
||||
|
||||
@@ -345,34 +401,22 @@ class _GameChatOverlayState extends State<GameChatOverlay> {
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
InteractiveViewer(child: Image.file(File(item.filePath))),
|
||||
|
||||
Positioned(
|
||||
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),
|
||||
),
|
||||
],
|
||||
top: 40,
|
||||
right: 20,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white, size: 30),
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
bottom: 20,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
color: Colors.black54,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(5)),
|
||||
child: Text("From: ${item.senderName}", style: const TextStyle(color: Colors.white)),
|
||||
bottom: 40,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.download, color: Colors.white, size: 30),
|
||||
tooltip: "저장",
|
||||
onPressed: () => _saveImageToGallery(context, item.filePath),
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -384,7 +428,7 @@ class _GameChatOverlayState extends State<GameChatOverlay> {
|
||||
await Gal.putImage(filePath);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("갤러리에 저장되었습니다! ✅")),
|
||||
const SnackBar(content: Text("저장되었습니다! ✅")),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../model/spider_model.dart';
|
||||
|
||||
class SpiderCardWidget extends StatelessWidget {
|
||||
final SpiderCard card;
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
const SpiderCardWidget({
|
||||
super.key,
|
||||
required this.card,
|
||||
required this.width,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: card.isFaceUp ? Colors.white : Colors.blue[800], // 뒷면 색상
|
||||
border: Border.all(color: Colors.black, width: 0.5),
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black26, blurRadius: 2, offset: const Offset(1, 1)),
|
||||
],
|
||||
),
|
||||
child: card.isFaceUp ? _buildFace() : _buildBack(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBack() {
|
||||
return Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.white, width: 1),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(Icons.pets, color: Colors.white30, size: 20),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFace() {
|
||||
return Stack(
|
||||
children: [
|
||||
// 왼쪽 상단 숫자
|
||||
Positioned(
|
||||
top: 2, left: 4,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(card.rankText, style: TextStyle(color: card.isRed ? Colors.red : Colors.black, fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
Text(card.suitSymbol, style: TextStyle(color: card.isRed ? Colors.red : Colors.black, fontSize: 10)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 중앙 심볼
|
||||
Center(
|
||||
child: Text(
|
||||
card.suitSymbol,
|
||||
style: TextStyle(color: card.isRed ? Colors.red : Colors.black, fontSize: width * 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 1. Sudoku Board (보드판)
|
||||
// -----------------------------------------------------------------------------
|
||||
class SudokuBoard extends StatelessWidget {
|
||||
final int blockSize;
|
||||
final List<int> cells;
|
||||
final List<int> originalCells;
|
||||
final int? selectedIndex;
|
||||
final int? selectedNumberPad;
|
||||
final Set<int> incorrectCells;
|
||||
final Function(int) onCellTapped;
|
||||
|
||||
const SudokuBoard({
|
||||
super.key,
|
||||
required this.blockSize,
|
||||
required this.cells,
|
||||
required this.originalCells,
|
||||
required this.selectedIndex,
|
||||
required this.selectedNumberPad,
|
||||
required this.incorrectCells,
|
||||
required this.onCellTapped,
|
||||
});
|
||||
|
||||
String _getSymbol(int value) {
|
||||
if (value == 0) return '';
|
||||
if (value >= 1 && value <= 9) return value.toString();
|
||||
if (value >= 10) return String.fromCharCode('A'.codeUnitAt(0) + (value - 10));
|
||||
return '?';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final int gridSize = blockSize * blockSize;
|
||||
final double fontSize = (gridSize > 9) ? 12 : 24;
|
||||
final bool isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// 심플한 색상 정의 (테마 의존성 제거)
|
||||
final Color thickBorderColor = isDark ? Colors.white70 : Colors.black87;
|
||||
final Color thinBorderColor = isDark ? Colors.white24 : Colors.black12;
|
||||
|
||||
final Color incorrectBg = Colors.red.withOpacity(0.2);
|
||||
final Color highlightedBg = Colors.blue.withOpacity(0.2);
|
||||
final Color selectedBg = Colors.blue.withOpacity(0.4); // 선택된 셀 배경
|
||||
final Color editableBg = isDark ? Colors.grey[800]! : Colors.white;
|
||||
final Color fixedBg = isDark ? Colors.grey[700]! : Colors.grey[200]!;
|
||||
|
||||
final Color selectedTextColor = Colors.white;
|
||||
final Color incorrectTextColor = Colors.red;
|
||||
final Color editableTextColor = Colors.blue[700]!;
|
||||
final Color fixedTextColor = isDark ? Colors.white : Colors.black;
|
||||
|
||||
return AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(border: Border.all(color: thickBorderColor, width: 2)),
|
||||
child: GridView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: gridSize,
|
||||
),
|
||||
itemCount: gridSize * gridSize,
|
||||
itemBuilder: (context, index) {
|
||||
int row = index ~/ gridSize;
|
||||
int col = index % gridSize;
|
||||
|
||||
int cellValue = cells[index];
|
||||
bool isEditable = (originalCells[index] == 0);
|
||||
bool isSelected = (index == selectedIndex);
|
||||
|
||||
// 같은 숫자가 선택되었을 때 하이라이트
|
||||
bool isHighlighted = (cellValue != 0 &&
|
||||
selectedNumberPad != null &&
|
||||
cellValue == selectedNumberPad);
|
||||
|
||||
bool isIncorrect = incorrectCells.contains(index);
|
||||
|
||||
// 테두리 그리기 (블록 경계는 두껍게)
|
||||
BorderSide rightBorder = (col % blockSize == blockSize - 1 && col != gridSize - 1)
|
||||
? BorderSide(color: thickBorderColor, width: 2.0)
|
||||
: BorderSide(color: thinBorderColor, width: 0.5);
|
||||
|
||||
BorderSide bottomBorder = (row % blockSize == blockSize - 1 && row != gridSize - 1)
|
||||
? BorderSide(color: thickBorderColor, width: 2.0)
|
||||
: BorderSide(color: thinBorderColor, width: 0.5);
|
||||
|
||||
Color bgColor = isEditable ? editableBg : fixedBg;
|
||||
if (isIncorrect) bgColor = incorrectBg;
|
||||
else if (isSelected) bgColor = selectedBg; // 선택된 셀이 우선
|
||||
else if (isHighlighted) bgColor = highlightedBg;
|
||||
|
||||
Color txtColor = isEditable ? editableTextColor : fixedTextColor;
|
||||
if (isSelected) txtColor = selectedTextColor;
|
||||
if (isIncorrect) txtColor = incorrectTextColor;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => onCellTapped(index),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
border: Border(right: rightBorder, bottom: bottomBorder),
|
||||
),
|
||||
child: Text(
|
||||
_getSymbol(cellValue),
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: txtColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 2. Number Pad (숫자 키패드)
|
||||
// -----------------------------------------------------------------------------
|
||||
class NumberPad extends StatelessWidget {
|
||||
final int blockSize;
|
||||
final Map<int, int> numberCounts;
|
||||
final int? selectedNumber;
|
||||
final Function(int) onNumberTapped;
|
||||
|
||||
const NumberPad({
|
||||
super.key,
|
||||
required this.blockSize,
|
||||
required this.numberCounts,
|
||||
required this.selectedNumber,
|
||||
required this.onNumberTapped,
|
||||
});
|
||||
|
||||
String _getSymbol(int value) {
|
||||
if (value >= 1 && value <= 9) return value.toString();
|
||||
if (value >= 10) return String.fromCharCode('A'.codeUnitAt(0) + (value - 10));
|
||||
return '?';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final int gridSize = blockSize * blockSize;
|
||||
// 가로 모드 등 복잡한 레이아웃 제거하고 단순 GridView로 통일
|
||||
return GridView.builder(
|
||||
itemCount: gridSize,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: blockSize > 3 ? 8 : blockSize * 3, // 적절히 줄 바꿈
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 1.2,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
int numberValue = index + 1;
|
||||
bool isSelected = (numberValue == selectedNumber);
|
||||
bool isCompleted = (numberCounts[numberValue] ?? 0) >= gridSize;
|
||||
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isSelected ? Colors.blue : (isCompleted ? Colors.grey[300] : Colors.white),
|
||||
foregroundColor: isSelected ? Colors.white : (isCompleted ? Colors.grey : Colors.black),
|
||||
elevation: isCompleted ? 0 : 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
onPressed: isCompleted ? null : () => onNumberTapped(numberValue),
|
||||
child: Text(
|
||||
_getSymbol(numberValue),
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user