This commit is contained in:
2025-11-14 18:03:50 +09:00
parent 1f5cea9a96
commit 13ed537b23
342 changed files with 18293 additions and 0 deletions
@@ -0,0 +1,292 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; // 👈 [추가]
import 'package:service_api/service_api.dart';
import '../models/game_result_args.dart';
// 스도쿠/스파이더와 동일한 enum
enum _RankSubmissionStep { enterName, submitting, showList }
class GameCompletionScreen extends StatefulWidget {
final GameResultArgs args;
const GameCompletionScreen({super.key, required this.args});
@override
State<GameCompletionScreen> createState() => _GameCompletionScreenState();
}
class _GameCompletionScreenState extends State<GameCompletionScreen> {
// 서비스 초기화
final PuzzleService _puzzleService = PuzzleService();
final IdentityService _identityService = IdentityService();
// 상태 변수
late final TextEditingController _nameController;
_RankSubmissionStep _rankStep = _RankSubmissionStep.enterName;
List<GameRankDto> _rankingList = [];
GameRankWithRankNumber? _myRankResult;
String? _dialogErrorMessage;
String _submittedPlayerName = "";
@override
void initState() {
super.initState();
// 🔽 [수정] 세션에서 userName을 가져옴
// (이 화면은 build 이전에 호출되므로 'read' 사용)
final session = context.read<SessionNotifier>().session;
_nameController = TextEditingController(text: session?.userName ?? widget.args.userName);
// 🔽 [수정] 게스트가 아니면, 이름 입력 단계를 건너뛰고 즉시 등록
if (session != null && !session.isGuest) {
_rankStep = _RankSubmissionStep.submitting;
// build가 완료된 후 등록 시작
WidgetsBinding.instance.addPostFrameCallback((_) {
// [중요] 세션의 userName으로 자동 제출
_submitRank(autoSubmitName: session.userName);
});
}
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
/// 랭킹 등록 로직 (공통화)
// 🔽 [수정] _submitRank가 자동 제출용 이름을 받도록
Future<void> _submitRank({String? autoSubmitName}) async {
String playerName;
// 자동 제출(로그인 상태)이 아니면(게스트면), 컨트롤러에서 이름을 가져옴
if (autoSubmitName == null) {
playerName = _nameController.text.trim();
if (playerName.isEmpty) {
setState(() { _dialogErrorMessage = "이름을 입력해주세요."; });
return;
}
} else {
playerName = autoSubmitName;
}
setState(() {
_rankStep = _RankSubmissionStep.submitting;
_submittedPlayerName = playerName;
_dialogErrorMessage = null;
});
final rankDto = UnifiedRankDto(
userId: widget.args.userId,
gameType: widget.args.gameType,
contextId: widget.args.contextId,
playerName: playerName, // 👈 [수정]
primaryScore: widget.args.primaryScore,
secondaryScore: widget.args.secondaryScore,
);
try {
// 1. 랭킹 등록
final RankSubmissionResult result = await _puzzleService.submitRank(rankDto);
// 2. 이름 저장 (공통)
// [수정] 게스트일 때만 이름을 저장 (소셜 로그인은 이미 이름이 있음)
if (autoSubmitName == null) {
await _identityService.saveUserName(playerName);
}
// 3. 게임별 후속 처리 (레벨 잠금 해제 등)
await widget.args.onProgressSave(playerName);
setState(() {
_rankingList = result.topRanks;
_myRankResult = result.myRank;
_rankStep = _RankSubmissionStep.showList;
});
} catch (e) {
log("!!! 랭킹 등록 실패 !!!", error: e);
setState(() {
_rankStep = _RankSubmissionStep.enterName;
// 🔽 [수정] 게스트가 아닐 때 실패하면, 이름 입력창 대신 리스트로 보냄
if (autoSubmitName != null) {
_rankStep = _RankSubmissionStep.showList;
}
_dialogErrorMessage = e.toString().replaceFirst("Exception: ", "");
});
}
}
/// 닫기 버튼 로직
void _closeScreen() {
// 1. 이 팝업 화면을 닫고
Navigator.of(context).pop();
// 2. 이전 화면(게임 화면)을 닫도록 콜백 호출
widget.args.onScreenClose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// --- UI 섹션 정의 (스도쿠/스파이더와 동일) ---
Widget topRankListWidget = _rankingList.isEmpty
? const Center(child: Text("현재 랭킹이 없습니다."))
: ListView.builder(
itemCount: _rankingList.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final rank = _rankingList[index];
final bool isMe = rank.playerName == _submittedPlayerName;
// [수정] 점수 포맷터를 주입받은 함수로 대체
final String scoreText = widget.args.scoreFormatter(
rank.primaryScore,
rank.secondaryScore
);
return ListTile(
selected: isMe,
selectedTileColor: theme.primaryColor.withOpacity(0.1),
leading: Text('${index + 1}.', style: const TextStyle(fontWeight: FontWeight.bold)),
title: Text(rank.playerName, style: TextStyle(fontWeight: isMe ? FontWeight.bold : FontWeight.normal)),
trailing: Text(
scoreText,
style: TextStyle(
fontWeight: FontWeight.bold,
color: theme.textTheme.bodyMedium?.color?.withOpacity(0.9)
)
),
);
},
);
Widget? myRankWidget;
if (_myRankResult != null) {
final myRank = _myRankResult!.rankData;
final myRankNum = _myRankResult!.rankNumber;
bool isMeInTop10 = _rankingList.any(
(topRank) => topRank.playerName == myRank.playerName
);
if (!isMeInTop10) {
// [수정] 점수 포맷터를 주입받은 함수로 대체
final String scoreText = widget.args.scoreFormatter(
myRank.primaryScore,
myRank.secondaryScore
);
myRankWidget = Padding(
padding: const EdgeInsets.only(top: 8.0),
child: ListTile(
selected: true,
selectedTileColor: theme.primaryColor.withOpacity(0.1),
leading: Text('$myRankNum.', style: const TextStyle(fontWeight: FontWeight.bold)),
title: Text(myRank.playerName, style: const TextStyle(fontWeight: FontWeight.bold)),
trailing: Text(
scoreText,
style: TextStyle(
fontWeight: FontWeight.bold,
color: theme.textTheme.bodyMedium?.color?.withOpacity(0.9)
)
),
),
);
}
}
Widget rankDisplaySection = Column(
children: [
if (_dialogErrorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(_dialogErrorMessage!, style: TextStyle(color: theme.colorScheme.error)),
),
Expanded(child: topRankListWidget),
if (myRankWidget != null) ...[
const Divider(height: 16, thickness: 1),
myRankWidget,
],
],
);
Widget nameEntryWidget = Column(
mainAxisSize: MainAxisSize.min,
children: [
// [수정] 게임별 점수 표시 대신 범용 텍스트
Text(
'축하합니다! 랭킹에 등록할 이름을 입력하세요.',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 20),
TextField(
controller: _nameController,
autofocus: true,
maxLength: 20, // [수정] 10 -> 20
decoration: InputDecoration(
labelText: '이름 (20자 이내)',
border: const OutlineInputBorder(),
errorText: _dialogErrorMessage,
),
),
],
);
// --- 상태에 따라 UI와 버튼 결정 ---
Widget content;
List<Widget> actions = [];
String titleText;
if (_rankStep == _RankSubmissionStep.enterName) {
titleText = '🎉 게임 완료!';
content = nameEntryWidget;
actions = [
TextButton(
onPressed: _closeScreen, // 닫기
child: const Text('나중에 하기'),
),
ElevatedButton(
onPressed: () => _submitRank(), // 👈 [수정] 인자 없이 호출
child: const Text('랭킹 등록'),
),
];
}
else if (_rankStep == _RankSubmissionStep.submitting) {
titleText = '랭킹 등록 중...';
content = const Center(child: CircularProgressIndicator());
// 로딩 중에는 버튼 없음
}
else { // _RankSubmissionStep.showList
titleText = '🏆 랭킹 (${widget.args.contextId})';
content = rankDisplaySection;
actions = [
TextButton(
onPressed: _closeScreen, // 닫기
child: const Text('닫기'),
)
];
}
// [수정] AlertDialog가 아닌 전체 화면 Scaffold로 변경
return Scaffold(
appBar: AppBar(
title: Text(titleText),
automaticallyImplyLeading: false, // 뒤로가기 버튼 숨김
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: content,
),
bottomNavigationBar: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: actions,
),
),
);
}
}
@@ -0,0 +1,156 @@
// // packages/feature_common/lib/screens/home_screen.dart
// import 'dart:developer';
// import 'package:flutter/material.dart';
// import 'package:provider/provider.dart';
// // 🔽 [수정] 서비스만 import하고, 모델은 새로 만든 GameInfo를 사용
// import 'package:service_api/service_api.dart';
// import '../models/game_info.dart'; // 👈 GameInfo 모델 import
// // 🔽 [수정] 내부에서 사용하던 위젯/화면 import
// import 'ranking_screen.dart';
// import 'settings_screen.dart';
// import '../widgets/ad_banner_widget.dart';
// class HomeScreen extends StatefulWidget {
// // 🔽 [수정] 'onStartGame' 대신 'availableGames' 리스트를 주입받음
// final List<GameInfo> availableGames;
// const HomeScreen({
// super.key,
// required this.availableGames, // 👈 생성자 변경
// });
// @override
// State<HomeScreen> createState() => _HomeScreenState();
// }
// class _HomeScreenState extends State<HomeScreen> {
// // 🔽 [삭제] 스도쿠 전용 상태 변수들 모두 삭제
// // int _maxUnlockedLevel = 1;
// // Map<int, (int, int)> _rankHistory = {};
// // String? _userName;
// // late String _selectedThemeName;
// // bool _isLoading = false;
// // 🔽 [삭제] 스도쿠 전용 서비스들 삭제
// // final PuzzleService _puzzleService = PuzzleService();
// // final IdentityService _identityService = IdentityService();
// @override
// void initState() {
// super.initState();
// // 🔽 [삭제] _loadProgress() 등 스도쿠 전용 로직 삭제
// }
// // 🔽 [삭제] _loadProgress 메서드 전체 삭제
// // Future<void> _loadProgress() async { ... }
// @override
// Widget build(BuildContext context) {
// context.watch<ThemeNotifier>();
// final theme = Theme.of(context);
// return Scaffold(
// appBar: AppBar(
// // 🔽 [수정] 앱 이름은 main.dart에서 설정하므로 여기선 비움
// title: const Text('게임 센터'),
// actions: [
// IconButton(
// icon: const Icon(Icons.settings_outlined),
// onPressed: () {
// Navigator.push(
// context,
// MaterialPageRoute(
// builder: (context) => const SettingsScreen(),
// ),
// );
// },
// ),
// ],
// ),
// body: LayoutBuilder(
// builder: (context, constraints) {
// const double maxContentRatio = 0.6;
// final double constrainedWidth = (constraints.maxHeight * maxContentRatio) > 500
// ? 500 : (constraints.maxHeight * maxContentRatio);
// return Center(
// child: ConstrainedBox(
// constraints: BoxConstraints(maxWidth: constrainedWidth),
// child: Column(
// children: [
// // 🔽 [삭제] 스도쿠 전용 '테마 선택' Dropdown 삭제
// // 2. 레벨 선택 리스트 (범용으로 변경)
// Expanded(
// // 🔽 [수정] ListView.builder가 주입받은 'widget.availableGames' 사용
// child: ListView.builder(
// itemCount: widget.availableGames.length,
// itemBuilder: (context, index) {
// final GameInfo game = widget.availableGames[index];
// return Card(
// margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
// child: ListTile(
// leading: Icon(
// game.icon, // 👈 GameInfo에서 아이콘 가져오기
// color: theme.primaryColor,
// ),
// title: Text(game.name, style: const TextStyle( // 👈 GameInfo에서 이름 가져오기
// fontSize: 18,
// fontWeight: FontWeight.bold,
// )),
// trailing: const Icon(Icons.play_arrow_rounded),
// // 🔽 [수정] onTap에 주입받은 game.onTap 함수 연결
// onTap: game.onTap,
// ),
// );
// },
// ),
// ),
// // 3. 랭킹 보기 버튼 (랭킹 스크린은 공통이므로 그대로 둠)
// Container(
// margin: const EdgeInsets.fromLTRB(16.0, 0, 16.0, 8.0),
// // ... (이하 랭킹 보기 버튼 스타일은 동일) ...
// child: InkWell(
// onTap: () {
// // 🔽 [수정] 스도쿠 레벨 대신 기본 랭킹 화면으로
// Navigator.push(
// context,
// MaterialPageRoute(
// builder: (context) => const RankingScreen(
// // initialDifficultyName: "중급 (9x9)", // 👈 필요시 하드코딩
// ),
// ),
// );
// },
// child: Container(
// width: double.infinity,
// padding: const EdgeInsets.symmetric(vertical: 14.0),
// child: Text(
// '🏆 전체 랭킹 보기',
// textAlign: TextAlign.center,
// style: TextStyle(
// fontSize: 16,
// fontWeight: FontWeight.bold,
// color: theme.colorScheme.onSurfaceVariant,
// ),
// ),
// ),
// ),
// // ...
// ),
// ],
// ),
// ),
// );
// },
// ),
// bottomNavigationBar: const AdBannerWidget(),
// );
// }
// }
@@ -0,0 +1,40 @@
// packages/feature_common/lib/screens/intro_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:service_api/service_api.dart'; // ThemeNotifier
import '../views/intro_view.dart'; // intro_view.dart 파일이 이 경로에 있어야 함
class IntroScreen extends StatelessWidget {
/// 인트로가 끝난 후 이동할 '다음 화면' (예: SudokuLobby or SpiderLobby)
final WidgetBuilder nextScreenBuilder;
const IntroScreen({
Key? key,
required this.nextScreenBuilder,
}) : super(key: key);
void _navigateToNextScreen(BuildContext context) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: nextScreenBuilder,
),
);
}
@override
Widget build(BuildContext context) {
final Color currentColor = context.watch<ThemeNotifier>().currentColor;
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Center(
child: IntroViewFlutter(
mainColor: currentColor,
onAnimationFinished: () {
_navigateToNextScreen(context);
},
),
),
);
}
}
@@ -0,0 +1,152 @@
// packages/feature_common/lib/screens/ranking_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:service_api/service_api.dart';
class RankingScreen extends StatefulWidget {
// 🔽 [수정] 생성자에서 3개의 값을 주입받음
final String gameType; // 'SUDOKU' 또는 'SPIDER'
final List<GameDifficulty> difficulties; // 표시할 난이도 목록
final String? initialDifficultyName; // 랭킹 버튼 클릭 시 전달된 초기값
const RankingScreen({
super.key,
required this.gameType,
required this.difficulties,
this.initialDifficultyName,
});
@override
State<RankingScreen> createState() => _RankingScreenState();
}
class _RankingScreenState extends State<RankingScreen> {
final PuzzleService _puzzleService = PuzzleService();
late Future<List<GameRankDto>> _rankingFuture;
late String _selectedDifficultyName;
@override
void initState() {
super.initState();
// 🔽 [수정] 주입받은 난이도 목록(widget.difficulties)을 사용
String defaultDifficultyName = widget.initialDifficultyName ?? widget.difficulties.first.name;
if (!widget.difficulties.any((d) => d.name == defaultDifficultyName)) {
defaultDifficultyName = widget.difficulties.first.name;
}
_fetchRanksForDifficulty(defaultDifficultyName);
}
void _fetchRanksForDifficulty(String difficultyName) {
setState(() {
_selectedDifficultyName = difficultyName;
// 🔽 [수정] 선택된 이름으로 contextId를 찾음
final String contextId = widget.difficulties
.firstWhere((d) => d.name == difficultyName)
.contextId;
// 🔽 [수정] 주입받은 widget.gameType 사용
_rankingFuture = _puzzleService.fetchRanks(widget.gameType, contextId);
});
}
String _formatTime(int seconds) {
final min = (seconds ~/ 60).toString().padLeft(2, '0');
final sec = (seconds % 60).toString().padLeft(2, '0');
return '$min:$sec';
}
String _formatScore(int? storedScore) {
int score = 5 - (storedScore ?? 5);
return 'SCORE: $score';
}
@override
Widget build(BuildContext context) {
context.watch<ThemeNotifier>();
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: Text('${widget.gameType} 랭킹')),
body: Column(
children: [
// 1. 난이도 선택 Dropdown
Padding(
padding: const EdgeInsets.all(16.0),
child: DropdownButton<String>(
value: _selectedDifficultyName,
isExpanded: true,
// 🔽 [수정] 주입받은 widget.difficulties로 메뉴 생성
items: widget.difficulties.map((level) {
return DropdownMenuItem<String>(
value: level.name,
child: Text(level.name),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
_fetchRanksForDifficulty(newValue);
}
},
),
),
// 2. 랭킹 리스트 (이하 build 로직은 원본과 동일)
Expanded(
child: FutureBuilder<List<GameRankDto>>(
future: _rankingFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('랭킹 로딩 실패: ${snapshot.error}'));
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Center(child: Text('등록된 랭킹이 없습니다.'));
}
final ranks = snapshot.data!;
return ListView.builder(
itemCount: ranks.length,
itemBuilder: (context, index) {
final rank = ranks[index];
return ListTile(
leading: Text(
'${index + 1}.',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
title: Text(rank.playerName, style: const TextStyle(fontSize: 18)),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatTime(rank.primaryScore),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: theme.primaryColor
),
),
Text(
_formatScore(rank.secondaryScore),
style: TextStyle(
fontSize: 12,
color: theme.textTheme.bodySmall?.color
),
),
],
),
);
},
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:service_api/service_api.dart';
import 'package:url_launcher/url_launcher.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
Future<void> _launchHomepage() async {
final Uri url = Uri.parse('https://lunaticbum.kr');
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
debugPrint('Could not launch $url');
}
}
@override
Widget build(BuildContext context) {
final themeNotifier = context.watch<ThemeNotifier>();
final sessionNotifier = context.watch<SessionNotifier>();
return Scaffold(
appBar: AppBar(
title: const Text('설정'),
),
body: ListView(
children: [
// 🔽 [수정] 계정 연동 섹션
ListTile(
leading: Icon(
sessionNotifier.isGuest
? Icons.person_outline
: Icons.person_rounded
),
title: Text(
sessionNotifier.isLoading
? '계정 정보 로딩 중...'
: (sessionNotifier.isGuest
? '게스트 계정'
: sessionNotifier.session?.userName ?? '로그인됨')
),
subtitle: Text(
sessionNotifier.isLoading
? ''
: (sessionNotifier.isGuest
? '진행 상황을 저장하려면 로그인하세요.'
: (sessionNotifier.session?.email ?? '소셜 계정'))
),
),
if (!sessionNotifier.isLoading)
if (sessionNotifier.isGuest)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.g_mobiledata), // (임시) Google 아이콘
label: const Text('Google 로그인'),
onPressed: () {
// 🔽 [수정] 로그인 함수 호출
sessionNotifier.login('google');
},
),
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.apple),
label: const Text('Apple 로그인'),
onPressed: () {
// 🔽 [수정] 로그인 함수 호출
sessionNotifier.login('apple');
},
),
),
],
),
)
else
ListTile(
title: const Text('로그아웃', style: TextStyle(color: Colors.red)),
leading: const Icon(Icons.logout, color: Colors.red),
onTap: () {
sessionNotifier.logout();
},
),
const Divider(),
// --- 0. 다크 모드 토글 ---
SwitchListTile(
title: const Text('다크 모드'),
secondary: const Icon(Icons.dark_mode_outlined),
value: themeNotifier.isDarkMode,
onChanged: (newValue) {
themeNotifier.toggleTheme(newValue);
},
),
const Divider(),
// --- 1. 테마 선택 섹션 ---
const Padding(
padding: EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 8.0),
child: Text(
'앱 테마 색상',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
...appColors.entries.map((entry) {
final String colorName = entry.key;
final MaterialColor color = entry.value;
return ListTile(
leading: CircleAvatar(
backgroundColor: color,
),
title: Text(colorName),
trailing: (themeNotifier.currentColor == color)
? Icon(Icons.check, color: Theme.of(context).colorScheme.secondary)
: null,
onTap: () {
themeNotifier.setTheme(colorName);
},
);
}),
const Divider(),
// --- 2. 라이선스 정보 섹션 ---
ListTile(
leading: const Icon(Icons.description_outlined),
title: const Text('오픈소스 라이선스'),
onTap: () {
showLicensePage(
context: context,
applicationName: '스도쿠 게임',
applicationVersion: '1.0.0',
applicationIcon: const Icon(Icons.apps_rounded, size: 64),
);
},
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('앱 정보'),
onTap: () {
showAboutDialog(
context: context,
applicationName: '스도쿠 게임',
applicationVersion: '1.0.0',
applicationIcon: const Icon(Icons.apps_rounded, size: 48),
children: [
const SizedBox(height: 16),
InkWell(
onTap: _launchHomepage,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'© 2025 lunaticbum',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
),
),
),
],
);
},
),
],
),
);
}
}