...
This commit is contained in:
@@ -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 "완전 크게";
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ description: The common UI shell for all games (Intro, Home, Settings, Ranking).
|
||||
version: 1.0.0
|
||||
publish_to: 'none'
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: '^3.9.2'
|
||||
flutter: '>=3.10.0'
|
||||
|
||||
Reference in New Issue
Block a user