This commit is contained in:
2025-11-26 18:10:10 +09:00
parent 283f08786e
commit bf40c42c2c
43 changed files with 4454 additions and 971 deletions
@@ -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();
}
}
+297 -253
View File
@@ -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),
),
);
},
);
}
}