This commit is contained in:
2025-12-15 18:18:17 +09:00
parent 03a7ed2ef2
commit 4c2c98de8a
216 changed files with 9831 additions and 725 deletions
@@ -12,5 +12,5 @@ export 'widgets/common_game_shell.dart';
// (views/intro_view.dart는 intro_screen.dart만 사용하므로 export 불필요)
export 'models/game_info.dart';
export 'models/game_result_args.dart';
export 'screens/game_completion_screen.dart';
export 'screens/base_game_screen.dart';
@@ -1,46 +0,0 @@
import 'package:flutter/material.dart';
/// 게임 완료 화면에 전달할 데이터 묶음
class GameResultArgs {
/// 랭킹 등록 시 사용할 게임 타입 (예: "SUDOKU", "SPIDER")
final String gameType;
/// 랭킹 등록 시 사용할 난이도 ID (예: "SUDOKU_9x9_L5")
final String contextId;
/// 랭킹 등록용 주 점수 (스도쿠: 시간, 스파이더: 이동 횟수)
final int primaryScore;
/// 랭킹 등록용 보조 점수 (스도쿠: (5-점수), 스파이더: 시간)
final int? secondaryScore;
/// 랭킹 등록에 필요한 유저 ID
final String userId;
/// 이름 입력 필드에 미리 채워줄 유저 이름
final String? userName;
/// 랭킹 목록에 점수를 표시할 포맷터 함수
/// 예: (120, 2) => "02:00 (Score: 3)"
final String Function(int primary, int? secondary) scoreFormatter;
/// 랭킹 등록 성공 시 호출될 게임별 후속 처리 콜백
/// (예: 다음 레벨 잠금 해제)
final Future<void> Function(String playerName) onProgressSave;
// ❌ [삭제] onScreenClose 콜백 제거
// final VoidCallback onScreenClose;
GameResultArgs({
required this.gameType,
required this.contextId,
required this.primaryScore,
this.secondaryScore,
required this.userId,
this.userName,
required this.scoreFormatter,
required this.onProgressSave,
// ❌ [삭제]
// required this.onScreenClose,
});
}
@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:service_api/service_api.dart';
class GameResultArgs {
final String gameType;
final String contextId;
final int primaryScore;
// [Legacy Support] 기존 게임 호환용 필드
final String? userId;
final String? userName; // [Fix] userName 필드 존재 확인
final int? secondaryScore;
// [Fix] 타입 불일치 해결: String을 받는 비동기 함수로 명시
final Future<void> Function(String)? onProgressSave;
final VoidCallback? onNextGame;
// [New Feature] 신규 게임 자동 저장용 필드
final int? stars;
final int? levelIndex;
final String Function(int score, int? subScore)? scoreFormatter;
GameResultArgs({
required this.gameType,
required this.contextId,
required this.primaryScore,
this.userId,
this.userName,
this.secondaryScore,
this.onProgressSave,
this.onNextGame,
this.stars,
this.levelIndex,
this.scoreFormatter,
});
}
abstract class BaseGameScreen extends StatefulWidget {
final VoidCallback? onNextGame;
const BaseGameScreen({super.key, this.onNextGame});
}
abstract class BaseGameScreenState<T extends BaseGameScreen> extends State<T> {
void showCommonGameCompletion(GameResultArgs args) async {
// 1. [New] 자동 저장 로직
if (args.stars != null && args.levelIndex != null) {
try {
if (!mounted) return;
final identityService = context.read<IdentityService>();
await identityService.submitGameResult(
gameType: args.gameType,
level: args.levelIndex!,
stars: args.stars!,
);
} catch (e) {
debugPrint("자동 저장 실패: $e");
}
}
// 2. [Legacy] 수동 저장 로직 호환 (기존 게임용)
if (args.onProgressSave != null) {
// 기존 게임들이 String 인자를 기대하므로 더미 문자열 전달
await args.onProgressSave!("legacy_save");
}
if (!mounted) return;
// 3. 팝업 표시
final VoidCallback? nextCallback = widget.onNextGame ?? args.onNextGame;
final bool isDailyCourse = nextCallback != null;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Row(
children: [
Icon(Icons.emoji_events, color: Colors.orange, size: 28),
SizedBox(width: 8),
Text('훈련 완료!'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
args.scoreFormatter != null
? args.scoreFormatter!(args.primaryScore, args.secondaryScore)
: "점수: ${args.primaryScore}",
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
if (args.stars != null)
Row(
children: List.generate(3, (index) => Icon(
index < args.stars! ? Icons.star : Icons.star_border,
color: Colors.amber,
size: 32,
)),
),
if (args.stars != null) const SizedBox(height: 16),
Text(isDailyCourse
? "수고하셨습니다. 다음 훈련으로 이동합니다."
: "수고하셨습니다. 로비로 돌아갑니다."
),
],
),
actions: [
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: () {
Navigator.pop(ctx);
if (isDailyCourse) {
nextCallback!();
} else {
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
}
},
child: Text(isDailyCourse ? "다음 게임" : "확인"),
),
],
),
);
}
}
@@ -1,328 +1,123 @@
// packages/feature_common/lib/screens/game_completion_screen.dart
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 _RankSubmissionStep { enterName, submitting, showList }
import 'base_game_screen.dart'; // GameResultArgs import
class GameCompletionScreen extends StatefulWidget {
final GameResultArgs args;
final bool isDailyCourse;
const GameCompletionScreen({super.key, required this.args});
const GameCompletionScreen({
super.key,
required this.args,
this.isDailyCourse = false,
});
@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 = "";
// 🔽 [신규] 랭킹 등록을 건너뛰었는지 확인하는 플래그
bool _didSkipRank = false;
late TextEditingController _nameController;
late IdentityService _identityService;
bool _isSaving = false;
String? _userName;
@override
void initState() {
super.initState();
// 레벨 클리어 (레벨 잠금 해제)를 즉시 호출
widget.args.onProgressSave("");
final session = context.read<SessionNotifier>().session;
_nameController = TextEditingController(text: session?.userName ?? widget.args.userName);
// 로그인 유저일 경우, 이름 입력 생략하고 자동 등록
if (session != null && !session.isGuest) {
_rankStep = _RankSubmissionStep.submitting;
WidgetsBinding.instance.addPostFrameCallback((_) {
_submitRank(autoSubmitName: session.userName);
});
}
_identityService = context.read<IdentityService>();
// [Fix] args.userName 사용 가능
String? initialName = widget.args.userName;
_nameController = TextEditingController(text: initialName ?? '');
_loadUserInfo();
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
Future<void> _submitRank({String? autoSubmitName}) async {
String playerName;
Future<void> _loadUserInfo() async {
final session = await _identityService.getUserSession();
String? name = session?.userName ?? await _identityService.getUserName();
if (autoSubmitName == null) {
playerName = _nameController.text.trim();
if (playerName.isEmpty) {
setState(() { _dialogErrorMessage = "이름을 입력해주세요."; });
return;
// 만약 args에 이름이 없고 저장된 이름이 있다면 불러옴
if (widget.args.userName == null && name != null) {
if (mounted) {
setState(() {
_nameController.text = name;
_userName = name;
});
}
} else {
playerName = autoSubmitName;
}
if (session != null && !session.isGuest) {
_saveProgress(name ?? "Unknown");
}
}
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,
);
Future<void> _saveProgress(String playerName) async {
if (_isSaving) return;
setState(() => _isSaving = true);
try {
final RankSubmissionResult result = await _puzzleService.submitRank(rankDto);
await _identityService.saveUserName(playerName);
if (autoSubmitName == null) {
await _identityService.saveUserName(playerName);
// [Fix] 타입 호환성 수정: String 인자 전달
if (widget.args.onProgressSave != null) {
await widget.args.onProgressSave!(playerName);
}
if (widget.args.stars != null && widget.args.levelIndex != null) {
await _identityService.submitGameResult(
gameType: widget.args.gameType,
level: widget.args.levelIndex!,
stars: widget.args.stars!
);
}
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: ", "");
});
debugPrint("Error saving progress: $e");
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
/// 🔽 [신규] 랭킹 등록 건너뛰기 및 화면 닫기
void _skipRankAndClose() {
setState(() {
_didSkipRank = true;
_rankStep = _RankSubmissionStep.showList; // 리스트 화면으로 전환하여 기록은 볼 수 있게 함
});
}
/// 🔽 [신규] 점수 표시 위젯 (최상단 고정)
Widget _buildScoreWidget(ThemeData theme) {
final String scoreText = widget.args.scoreFormatter(
widget.args.primaryScore, widget.args.secondaryScore);
return Padding(
padding: const EdgeInsets.only(bottom: 20.0),
child: Card(
color: theme.colorScheme.primary.withOpacity(0.1),
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'나의 최종 기록',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 8),
Text(
scoreText,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
],
),
),
),
);
}
/// 🔽 [신규] 이름 입력 및 버튼 섹션 (키보드 대응)
Widget _buildNameEntrySection(ThemeData theme) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('축하합니다! 랭킹에 등록할 이름을 입력하세요.', style: theme.textTheme.titleMedium),
const SizedBox(height: 20),
TextField(
controller: _nameController,
autofocus: true,
maxLength: 20,
decoration: InputDecoration(
labelText: '이름 (20자 이내)',
border: const OutlineInputBorder(),
errorText: _dialogErrorMessage,
),
),
const SizedBox(height: 16),
// [🔥 수정] 버튼을 입력창 바로 아래 배치
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _skipRankAndClose, child: const Text('건너뛰기')),
const SizedBox(width: 10),
ElevatedButton(
onPressed: () => _submitRank(), child: const Text('랭킹 등록')),
],
),
],
);
}
/// 🔽 [신규] 랭킹 리스트 섹션 (기록 보기)
Widget _buildRankingListSection(ThemeData theme) {
if (_didSkipRank) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('랭킹 등록을 건너뛰었습니다.', style: theme.textTheme.titleMedium),
const SizedBox(height: 10),
Text('기록은 위 "나의 최종 기록"에서 확인 가능합니다.', style: theme.textTheme.bodyMedium),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('로비로 돌아가기'),
)
],
),
);
}
// 랭킹 리스트 (기존 로직과 유사)
Widget topRankListWidget = _rankingList.isEmpty
? const Center(child: Text("등록된 랭킹이 없습니다."))
: ListView.builder(
itemCount: _rankingList.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), // SingleChildScrollView 내부이므로 필요
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.colorScheme.secondary.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))),
),
);
}
}
return Expanded(
child: SingleChildScrollView(
child: Column(
children: [
if (_dialogErrorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(_dialogErrorMessage!, style: TextStyle(color: theme.colorScheme.error)),
),
topRankListWidget,
if (myRankWidget != null) ...[
const Divider(height: 16, thickness: 1),
myRankWidget,
],
const SizedBox(height: 40),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('로비로 돌아가기'),
)
],
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
String titleText;
Widget content;
if (_rankStep == _RankSubmissionStep.enterName) {
titleText = '🎉 게임 완료!';
content = _buildNameEntrySection(theme); // 이름 입력 섹션
}
else if (_rankStep == _RankSubmissionStep.submitting) {
titleText = '랭킹 등록 중...';
content = const Center(child: CircularProgressIndicator());
}
else { // _RankSubmissionStep.showList
titleText = _didSkipRank ? '✅ 기록 확인' : '🏆 랭킹 등록 완료';
content = _buildRankingListSection(theme); // 랭킹 리스트 섹션
}
return Scaffold(
appBar: AppBar(
title: Text(titleText),
automaticallyImplyLeading: false,
),
body: SafeArea(
appBar: AppBar(title: const Text('결과')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// 1. [🔥 수정] 최종 기록 섹션 (스크롤과 분리된 최상단)
_buildScoreWidget(theme),
// 2. [🔥 수정] 메인 컨텐츠 섹션
if (_rankStep == _RankSubmissionStep.enterName || _rankStep == _RankSubmissionStep.submitting)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: content,
)
else
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: content,
),
const Icon(Icons.emoji_events, size: 80, color: Colors.orange),
const SizedBox(height: 24),
Text(
"점수: ${widget.args.primaryScore}",
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 32),
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: '이름을 입력하세요',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () async {
await _saveProgress(_nameController.text);
if (!mounted) return;
if (widget.args.onNextGame != null) {
widget.args.onNextGame!();
} else {
Navigator.pop(context);
}
},
child: const Text('확인'),
)
],
),
),
// ❌ bottomNavigationBar는 제거됨
);
}
}
@@ -13,18 +13,48 @@ class SettingsScreen extends StatelessWidget {
}
}
// 🔽 [신규] 기록 삭제 확인 다이얼로그
Future<void> _confirmClearHistory(BuildContext context) async {
final bool? confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('진단 기록 삭제'),
content: const Text('저장된 모든 두뇌 진단 기록을 삭제하시겠습니까?\n삭제된 데이터는 복구할 수 없습니다.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('취소'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('삭제'),
),
],
),
);
if (confirmed == true && context.mounted) {
final identityService = context.read<IdentityService>();
await identityService.clearAssessmentHistory();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('진단 기록이 삭제되었습니다.')),
);
}
}
}
@override
Widget build(BuildContext context) {
final themeNotifier = context.watch<ThemeNotifier>();
final sessionNotifier = context.watch<SessionNotifier>();
return Scaffold(
appBar: AppBar(
title: const Text('설정'),
),
appBar: AppBar(title: const Text('설정')),
body: ListView(
children: [
// 🔽 [수정] 계정 연동 섹션
ListTile(
leading: Icon(
sessionNotifier.isGuest
@@ -55,10 +85,9 @@ class SettingsScreen extends StatelessWidget {
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.g_mobiledata), // (임시) Google 아이콘
icon: const Icon(Icons.g_mobiledata),
label: const Text('Google 로그인'),
onPressed: () {
// 🔽 [수정] 로그인 함수 호출
sessionNotifier.login('google');
},
),
@@ -69,7 +98,6 @@ class SettingsScreen extends StatelessWidget {
icon: const Icon(Icons.apple),
label: const Text('Apple 로그인'),
onPressed: () {
// 🔽 [수정] 로그인 함수 호출
sessionNotifier.login('apple');
},
),
@@ -88,7 +116,6 @@ class SettingsScreen extends StatelessWidget {
const Divider(),
// --- 0. 다크 모드 토글 ---
SwitchListTile(
title: const Text('다크 모드'),
secondary: const Icon(Icons.dark_mode_outlined),
@@ -98,9 +125,60 @@ class SettingsScreen extends StatelessWidget {
},
),
// 🔽 [신규] 글자 크기 조절 섹션
ListTile(
title: const Text('글자 크기'),
subtitle: Text(_getScaleLabel(themeNotifier.textScaleFactor)),
leading: const Icon(Icons.format_size),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
children: [
Slider(
value: themeNotifier.textScaleFactor,
min: 0.85,
max: 1.5,
divisions: 4,
label: _getScaleLabel(themeNotifier.textScaleFactor),
onChanged: (value) {
themeNotifier.setTextScale(value);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
Text("작게", style: TextStyle(fontSize: 12)),
Text("표준", style: TextStyle(fontSize: 12)),
Text("크게", style: TextStyle(fontSize: 12)),
Text("더 크게", style: TextStyle(fontSize: 12)),
Text("완전 크게", style: TextStyle(fontSize: 12)),
],
),
const SizedBox(height: 16),
],
),
),
const Divider(),
// 🔽 [신규] 데이터 관리 섹션
const Padding(
padding: EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 8.0),
child: Text(
'데이터 관리',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: const Text('진단 기록 삭제', style: TextStyle(color: Colors.red)),
subtitle: const Text('저장된 두뇌 건강 진단 기록을 모두 지웁니다.'),
onTap: () => _confirmClearHistory(context),
),
const Divider(),
// --- 1. 테마 선택 섹션 ---
const Padding(
padding: EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 8.0),
child: Text(
@@ -129,7 +207,6 @@ class SettingsScreen extends StatelessWidget {
const Divider(),
// --- 2. 라이선스 정보 섹션 ---
ListTile(
leading: const Icon(Icons.description_outlined),
title: const Text('오픈소스 라이선스'),
@@ -172,9 +249,16 @@ class SettingsScreen extends StatelessWidget {
);
},
),
],
),
);
}
String _getScaleLabel(double scale) {
if (scale <= 0.9) return "작게";
if (scale <= 1.05) return "표준";
if (scale <= 1.2) return "크게";
if (scale <= 1.3) return "더 크게";
return "완전 크게";
}
}