...
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
/coverage/
|
||||
@@ -0,0 +1,10 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: package
|
||||
@@ -0,0 +1,3 @@
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
@@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
||||
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
This README describes the package. If you publish this package to pub.dev,
|
||||
this README's contents appear on the landing page for your package.
|
||||
|
||||
For information about how to write a good package README, see the guide for
|
||||
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
|
||||
|
||||
For general information about developing packages, see the Dart guide for
|
||||
[creating packages](https://dart.dev/guides/libraries/create-packages)
|
||||
and the Flutter guide for
|
||||
[developing packages and plugins](https://flutter.dev/to/develop-packages).
|
||||
-->
|
||||
|
||||
TODO: Put a short description of the package here that helps potential users
|
||||
know whether this package might be useful for them.
|
||||
|
||||
## Features
|
||||
|
||||
TODO: List what your package can do. Maybe include images, gifs, or videos.
|
||||
|
||||
## Getting started
|
||||
|
||||
TODO: List prerequisites and provide or point to information on how to
|
||||
start using the package.
|
||||
|
||||
## Usage
|
||||
|
||||
TODO: Include short and useful examples for package users. Add longer examples
|
||||
to `/example` folder.
|
||||
|
||||
```dart
|
||||
const like = 'sample';
|
||||
```
|
||||
|
||||
## Additional information
|
||||
|
||||
TODO: Tell users more about the package: where to find more information, how to
|
||||
contribute to the package, how to file issues, what response they can expect
|
||||
from the package authors, and more.
|
||||
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,16 @@
|
||||
// packages/feature_common/lib/feature_common.dart
|
||||
|
||||
// 앱(app_*)이 사용할 공통 화면
|
||||
export 'screens/intro_screen.dart';
|
||||
|
||||
// 게임(feature_game_*) 패키지가 사용할 공통 위젯/화면
|
||||
export 'screens/ranking_screen.dart';
|
||||
export 'screens/settings_screen.dart';
|
||||
export 'widgets/ad_banner_widget.dart';
|
||||
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';
|
||||
@@ -0,0 +1,21 @@
|
||||
// packages/feature_common/lib/models/game_info.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// HomeScreen이 표시할 게임 목록의 정보 모델.
|
||||
/// 최종 'app' 패키지가 이 정보를 채워서 HomeScreen에 주입합니다.
|
||||
class GameInfo {
|
||||
final String id;
|
||||
final String name;
|
||||
final IconData icon;
|
||||
|
||||
/// [핵심] 이 게임을 눌렀을 때 실행될 실제 동작 (예: 화면 이동).
|
||||
final VoidCallback onTap;
|
||||
|
||||
GameInfo({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
|
||||
/// 팝업이 닫힐 때 게임 화면을 닫기 위한 콜백
|
||||
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,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart'; // 👈 [핵심] 이 import 문이 누락되었습니다.
|
||||
|
||||
/// [수정] "SBSPACE"를 한 줄로 그리는 IntroView
|
||||
///
|
||||
/// @param mainColor 뷰의 텍스트 색상 (S, B 강조)
|
||||
/// @param onAnimationFinished 뷰의 애니메이션이 완료될 때 호출될 콜백
|
||||
class IntroViewFlutter extends StatefulWidget {
|
||||
final Color mainColor;
|
||||
final VoidCallback onAnimationFinished;
|
||||
|
||||
const IntroViewFlutter({
|
||||
super.key,
|
||||
required this.mainColor,
|
||||
required this.onAnimationFinished,
|
||||
});
|
||||
|
||||
@override
|
||||
_IntroViewFlutterState createState() => _IntroViewFlutterState();
|
||||
}
|
||||
|
||||
class _IntroViewFlutterState extends State<IntroViewFlutter>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
late final Animation<int> _logoTextAnimation; // 0 -> 7
|
||||
late final Animation<int> _missionTextAnimation; // 0 -> 15
|
||||
|
||||
static const String _logoString = "SBSPACE";
|
||||
static const String _missionString = "Simple is Best.";
|
||||
static const String _fontFamily = "Sdmisaeng";
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
const int logoDuration = _logoString.length * 150; // 7 * 150ms = 1050ms
|
||||
const int missionDuration = _missionString.length * 100; // 15 * 100ms = 1500ms
|
||||
final int totalAnimationDuration = logoDuration + missionDuration; // 2550ms
|
||||
|
||||
_controller = AnimationController(
|
||||
duration: Duration(milliseconds: totalAnimationDuration),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
// 로고 타이핑 애니메이션 (0 -> 7)
|
||||
_logoTextAnimation = IntTween(begin: 0, end: _logoString.length).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Interval(
|
||||
0.0,
|
||||
logoDuration / totalAnimationDuration,
|
||||
curve: Curves.linear,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 미션 타이핑 애니메이션 (0 -> 15)
|
||||
_missionTextAnimation = IntTween(begin: 0, end: _missionString.length).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Interval(
|
||||
logoDuration / totalAnimationDuration,
|
||||
1.0,
|
||||
curve: Curves.linear,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
_controller.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (mounted) {
|
||||
widget.onAnimationFinished();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return CustomPaint(
|
||||
painter: _IntroPainter(
|
||||
mainColor: widget.mainColor,
|
||||
fontFamily: _fontFamily,
|
||||
logoTextLength: _logoTextAnimation.value,
|
||||
missionTextLength: _missionTextAnimation.value,
|
||||
),
|
||||
size: Size.infinite,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [수정] RichText(TextSpan)를 사용해 그리는 CustomPainter
|
||||
class _IntroPainter extends CustomPainter {
|
||||
final Color mainColor;
|
||||
final String fontFamily;
|
||||
final int logoTextLength;
|
||||
final int missionTextLength;
|
||||
|
||||
static const String _logoString = "SBSPACE";
|
||||
static const String _missionString = "Simple is Best.";
|
||||
|
||||
_IntroPainter({
|
||||
required this.mainColor,
|
||||
required this.fontFamily,
|
||||
required this.logoTextLength,
|
||||
required this.missionTextLength,
|
||||
});
|
||||
|
||||
/// 헬퍼: 현재 길이에 맞는 "SBSPACE" TextSpan을 생성
|
||||
TextSpan _buildLogoSpan(int length) {
|
||||
final Color normalColor = mainColor.withOpacity(0.6);
|
||||
final List<TextSpan> children = [];
|
||||
|
||||
if (length >= 1) {
|
||||
children.add(TextSpan(text: "S", style: TextStyle(color: mainColor)));
|
||||
}
|
||||
if (length >= 2) {
|
||||
children.add(TextSpan(text: "B", style: TextStyle(color: mainColor)));
|
||||
}
|
||||
if (length >= 3) {
|
||||
final String spaceToDraw = _logoString.substring(2, length.clamp(2, _logoString.length));
|
||||
children.add(TextSpan(text: spaceToDraw, style: TextStyle(color: normalColor)));
|
||||
}
|
||||
|
||||
return TextSpan(
|
||||
style: TextStyle(fontFamily: fontFamily, fontWeight: FontWeight.bold),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
/// 헬퍼: TextSpan과 폰트 크기로 TextPainter를 생성 (레이아웃 포함)
|
||||
TextPainter _createTextPainter(TextSpan textSpan, double fontSize) {
|
||||
final style = textSpan.style!.copyWith(fontSize: fontSize);
|
||||
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(children: textSpan.children, style: style),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
painter.layout();
|
||||
return painter;
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// 1. 로고 폰트 크기 정의
|
||||
final double logoFontSize = size.shortestSide / 6.0;
|
||||
|
||||
// 2. 미션 폰트 크기를 화면 너비에 꽉 차게 동적 계산
|
||||
|
||||
// 2a. 임시 폰트 크기(100)로 TextPainter를 생성하여 원본 너비를 측정
|
||||
const double tempMissionFontSize = 100.0;
|
||||
final TextPainter tpMissionTemp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: _missionString,
|
||||
style: TextStyle(fontFamily: fontFamily, fontWeight: FontWeight.bold, fontSize: tempMissionFontSize)
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
|
||||
// 2b. (화면 너비 * 0.9) / (임시 텍스트 너비) = 스케일 비율
|
||||
final double targetWidth = size.width * 0.9;
|
||||
final double scale = targetWidth / tpMissionTemp.width;
|
||||
|
||||
// 2c. 실제 폰트 크기 계산
|
||||
final double missionFontSize = tempMissionFontSize * scale;
|
||||
|
||||
// 3. 레이아웃 계산용 TextPainter (항상 전체 텍스트 기준)
|
||||
|
||||
// 3a. 로고
|
||||
final tpLogoFull = _createTextPainter(_buildLogoSpan(_logoString.length), logoFontSize);
|
||||
|
||||
// 3b. 미션 (계산된 missionFontSize 사용)
|
||||
final TextPainter tpMissionFull = TextPainter(
|
||||
text: TextSpan(
|
||||
text: _missionString,
|
||||
style: TextStyle(
|
||||
color: mainColor,
|
||||
fontSize: missionFontSize,
|
||||
fontFamily: fontFamily,
|
||||
fontWeight: FontWeight.bold
|
||||
)
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
|
||||
|
||||
// 4. 전체 텍스트 블록의 세로 중앙 정렬 Y좌표 계산
|
||||
final double padding = logoFontSize * 0.1;
|
||||
final double totalHeight = tpLogoFull.height + padding + tpMissionFull.height;
|
||||
final double startyLogo = (size.height - totalHeight) / 2.0;
|
||||
final double startyMission = startyLogo + tpLogoFull.height + padding;
|
||||
|
||||
// 5. 각 텍스트 라인의 가로 중앙 정렬 X좌표 계산
|
||||
final double startxLogo = (size.width - tpLogoFull.width) / 2.0;
|
||||
final double startxMission = (size.width - tpMissionFull.width) / 2.0;
|
||||
|
||||
// 6. 그리기용 TextPainter (애니메이션 적용된 길이 기준)
|
||||
|
||||
// 6a. 로고 그리기 (현재 길이: logoTextLength)
|
||||
final tpLogoSub = _createTextPainter(_buildLogoSpan(logoTextLength), logoFontSize);
|
||||
tpLogoSub.paint(canvas, Offset(startxLogo, startyLogo));
|
||||
|
||||
// 6b. 미션 그리기 (현재 길이: missionTextLength)
|
||||
final String missionToDraw = _missionString.substring(0, missionTextLength);
|
||||
final tpMissionSub = TextPainter(
|
||||
text: TextSpan(text: missionToDraw, style: tpMissionFull.text!.style),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
|
||||
tpMissionSub.paint(canvas, Offset(startxMission, startyMission));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _IntroPainter oldDelegate) {
|
||||
return oldDelegate.mainColor != mainColor ||
|
||||
oldDelegate.logoTextLength != logoTextLength ||
|
||||
oldDelegate.missionTextLength != missionTextLength;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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 _isAdLoaded = false;
|
||||
|
||||
// TODO: 릴리스 시 실제 Ad Unit ID로 교체하세요.
|
||||
final String _androidAdUnitId = 'ca-app-pub-3940256099942544/6300978111'; // 테스트 ID
|
||||
final String _iosAdUnitId = 'ca-app-pub-3940256099942544/2934735716'; // 테스트 ID
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAd();
|
||||
}
|
||||
|
||||
void _loadAd() {
|
||||
_bannerAd = BannerAd(
|
||||
adUnitId: Platform.isAndroid ? _androidAdUnitId : _iosAdUnitId,
|
||||
size: AdSize.banner,
|
||||
request: const AdRequest(),
|
||||
listener: BannerAdListener(
|
||||
onAdLoaded: (Ad ad) {
|
||||
setState(() {
|
||||
_isAdLoaded = true;
|
||||
});
|
||||
},
|
||||
onAdFailedToLoad: (Ad ad, LoadAdError error) {
|
||||
print('Ad failed to load: $error');
|
||||
ad.dispose();
|
||||
},
|
||||
),
|
||||
)..load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bannerAd?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isAdLoaded && _bannerAd != null) {
|
||||
// 광고가 로드되면 광고 위젯을 표시
|
||||
return Container(
|
||||
width: _bannerAd!.size.width.toDouble(),
|
||||
height: _bannerAd!.size.height.toDouble(),
|
||||
alignment: Alignment.center,
|
||||
child: AdWidget(ad: _bannerAd!),
|
||||
);
|
||||
} else {
|
||||
// 로드되지 않았으면, 광고 높이만큼의 빈 공간만 차지
|
||||
return Container(
|
||||
height: AdSize.banner.height.toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// packages/feature_common/lib/widgets/common_game_shell.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import '../screens/ranking_screen.dart';
|
||||
import '../screens/settings_screen.dart';
|
||||
import 'ad_banner_widget.dart';
|
||||
|
||||
/// 모든 게임 앱이 공유하는 '공통 셸' 위젯
|
||||
/// (AppBar, 설정/랭킹 버튼, 하단 광고 배너 포함)
|
||||
class CommonGameShell extends StatelessWidget {
|
||||
final Widget body;
|
||||
final String title;
|
||||
|
||||
/// 랭킹 버튼을 눌렀을 때 실행될 함수 (외부 주입)
|
||||
/// null을 전달하면 랭킹 버튼이 비활성화됩니다.
|
||||
final VoidCallback? onRankingPressed;
|
||||
|
||||
const CommonGameShell({
|
||||
super.key,
|
||||
required this.body,
|
||||
required this.title,
|
||||
this.onRankingPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title),
|
||||
actions: [
|
||||
// 랭킹 버튼
|
||||
IconButton(
|
||||
icon: const Icon(Icons.leaderboard_outlined),
|
||||
onPressed: onRankingPressed, // 👈 주입받은 함수 실행
|
||||
),
|
||||
// 설정 버튼
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: body, // 👈 주입받은 내용물
|
||||
bottomNavigationBar: const AdBannerWidget(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
name: feature_common
|
||||
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'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# 1. 공통 서비스 로직 (필수)
|
||||
# service_api 패키지에 정의된 모든 서비스를 사용합니다.
|
||||
service_api:
|
||||
path: ../service_api # 👈 Melos가 관리하지만, 로컬 경로를 명시해도 좋습니다.
|
||||
|
||||
# 2. UI 및 상태 관리
|
||||
provider: ^6.0.0 # (SettingsScreen, HomeScreen 등에서 ThemeNotifier 사용)
|
||||
|
||||
# 3. 공통 위젯용
|
||||
google_mobile_ads: ^5.0.0 # (AdBannerWidget이 사용)
|
||||
url_launcher: ^6.3.2
|
||||
# (SudokuTheme, AppLevels 등은 service_api에 있으므로 여기엔 필요X)
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
|
||||
void main() {
|
||||
test('adds one to input values', () {
|
||||
final calculator = Calculator();
|
||||
expect(calculator.addOne(2), 3);
|
||||
expect(calculator.addOne(-7), -6);
|
||||
expect(calculator.addOne(0), 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
/coverage/
|
||||
@@ -0,0 +1,10 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: package
|
||||
@@ -0,0 +1,3 @@
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
@@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
||||
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
This README describes the package. If you publish this package to pub.dev,
|
||||
this README's contents appear on the landing page for your package.
|
||||
|
||||
For information about how to write a good package README, see the guide for
|
||||
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
|
||||
|
||||
For general information about developing packages, see the Dart guide for
|
||||
[creating packages](https://dart.dev/guides/libraries/create-packages)
|
||||
and the Flutter guide for
|
||||
[developing packages and plugins](https://flutter.dev/to/develop-packages).
|
||||
-->
|
||||
|
||||
TODO: Put a short description of the package here that helps potential users
|
||||
know whether this package might be useful for them.
|
||||
|
||||
## Features
|
||||
|
||||
TODO: List what your package can do. Maybe include images, gifs, or videos.
|
||||
|
||||
## Getting started
|
||||
|
||||
TODO: List prerequisites and provide or point to information on how to
|
||||
start using the package.
|
||||
|
||||
## Usage
|
||||
|
||||
TODO: Include short and useful examples for package users. Add longer examples
|
||||
to `/example` folder.
|
||||
|
||||
```dart
|
||||
const like = 'sample';
|
||||
```
|
||||
|
||||
## Additional information
|
||||
|
||||
TODO: Tell users more about the package: where to find more information, how to
|
||||
contribute to the package, how to file issues, what response they can expect
|
||||
from the package authors, and more.
|
||||
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,417 @@
|
||||
// packages/feature_game_spider/lib/controllers/spider_game_controller.dart
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
import '../models/spider_card.dart';
|
||||
import '../models/spider_difficulty.dart';
|
||||
import '../models/spider_game_state.dart';
|
||||
|
||||
class SpiderGameController with ChangeNotifier {
|
||||
late final SpiderDifficulty difficulty;
|
||||
late final String userId;
|
||||
late final String? userName;
|
||||
|
||||
late SpiderGameState _currentState;
|
||||
SpiderGameState get currentState => _currentState;
|
||||
final List<SpiderGameHistory> _undoHistory = [];
|
||||
|
||||
Timer? _timer;
|
||||
int _secondsElapsed = 0;
|
||||
int get secondsElapsed => _secondsElapsed;
|
||||
|
||||
bool _isGameCompleted = false;
|
||||
bool get isGameCompleted => _isGameCompleted;
|
||||
|
||||
List<SpiderCard> _draggedCards = [];
|
||||
List<SpiderCard> get draggedCards => _draggedCards;
|
||||
|
||||
int _undoCount = 0;
|
||||
int get undoCount => _undoCount;
|
||||
static const int maxUndoCount = 5;
|
||||
|
||||
List<SpiderCard> _cardsToDealAnimate = [];
|
||||
List<SpiderCard> get cardsToDealAnimate => _cardsToDealAnimate;
|
||||
|
||||
void clearDealAnimationTrigger() {
|
||||
debugPrint("[LOG] clearDealAnimationTrigger: CALLED. Clearing animation queue (Current count: ${_cardsToDealAnimate.length}).");
|
||||
_cardsToDealAnimate.clear();
|
||||
}
|
||||
|
||||
List<SpiderCard> _cardsToAnimateStack = [];
|
||||
List<SpiderCard> get cardsToAnimateStack => _cardsToAnimateStack;
|
||||
int _animationSourcePileIndex = -1;
|
||||
int get animationSourcePileIndex => _animationSourcePileIndex;
|
||||
int _animationTargetFoundationIndex = -1;
|
||||
int get animationTargetFoundationIndex => _animationTargetFoundationIndex;
|
||||
|
||||
bool get canUndo {
|
||||
return _undoHistory.isNotEmpty &&
|
||||
!_isGameCompleted &&
|
||||
_undoCount < maxUndoCount;
|
||||
}
|
||||
|
||||
void setUserInfo(String userId, String? userName) {
|
||||
this.userId = userId;
|
||||
this.userName = userName;
|
||||
}
|
||||
|
||||
void startNewGame(SpiderDifficulty difficulty) {
|
||||
this.difficulty = difficulty;
|
||||
_undoHistory.clear();
|
||||
_isGameCompleted = false;
|
||||
_undoCount = 0;
|
||||
_cardsToDealAnimate = [];
|
||||
_cardsToAnimateStack = [];
|
||||
|
||||
final List<SpiderCard> deck = _createDeck(difficulty.numSuits);
|
||||
deck.shuffle(Random());
|
||||
final (List<List<SpiderCard>> tableau, List<SpiderCard> stock) =
|
||||
_dealCards(deck, difficulty.numCardsDistribution);
|
||||
|
||||
_currentState = SpiderGameState(
|
||||
tableau: tableau,
|
||||
stock: stock,
|
||||
foundation: [], // 👈 비어있는 리스트
|
||||
moves: 0,
|
||||
);
|
||||
_startTimer();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void restartGame() {
|
||||
startNewGame(difficulty);
|
||||
}
|
||||
|
||||
// ( _createDeck, _dealCards, _startTimer, stopTimer 는 동일 )
|
||||
List<SpiderCard> _createDeck(int numSuits) {
|
||||
final List<SpiderSuit> suitsToUse =
|
||||
SpiderSuit.values.take(numSuits).toList();
|
||||
final List<SpiderCard> deck = [];
|
||||
int cardId = 0;
|
||||
final int setsPerSuit = (104 / 13) ~/ numSuits;
|
||||
for (int i = 0; i < setsPerSuit; i++) {
|
||||
for (final suit in suitsToUse) {
|
||||
for (int rank = 1; rank <= 13; rank++) {
|
||||
deck.add(SpiderCard(id: cardId++, suit: suit, rank: rank));
|
||||
}
|
||||
}
|
||||
}
|
||||
return deck;
|
||||
}
|
||||
(List<List<SpiderCard>>, List<SpiderCard>) _dealCards(
|
||||
List<SpiderCard> shuffledDeck, String distribution) {
|
||||
final List<List<SpiderCard>> tableau = List.generate(10, (_) => []);
|
||||
final List<SpiderCard> stock = List.from(shuffledDeck);
|
||||
final parts = distribution.split(',');
|
||||
final int longStacksCount = 4;
|
||||
final int longStackSize = int.parse(parts[0]);
|
||||
final int shortStackSize = int.parse(parts[1]);
|
||||
for (int i = 0; i < 10; i++) {
|
||||
final int stackSize = (i < longStacksCount) ? longStackSize : shortStackSize;
|
||||
for (int j = 0; j < stackSize; j++) {
|
||||
tableau[i].add(stock.removeLast());
|
||||
}
|
||||
if (tableau[i].isNotEmpty) {
|
||||
tableau[i].last.isFaceUp = true;
|
||||
}
|
||||
}
|
||||
return (tableau, stock);
|
||||
}
|
||||
void _startTimer() {
|
||||
_timer?.cancel();
|
||||
_secondsElapsed = 0;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
_secondsElapsed++;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
void stopTimer() {
|
||||
_timer?.cancel();
|
||||
}
|
||||
|
||||
/// 🔽 덱 분배 (애니메이션 트리거)
|
||||
void dealFromStock() {
|
||||
debugPrint("[LOG] dealFromStock: CALLED. Checking conditions...");
|
||||
|
||||
if (_currentState.stock.isEmpty) {
|
||||
debugPrint("[LOG] dealFromStock: FAILED (Stock is empty)");
|
||||
return;
|
||||
}
|
||||
if (_isGameCompleted) {
|
||||
debugPrint("[LOG] dealFromStock: FAILED (Game completed)");
|
||||
return;
|
||||
}
|
||||
if (_cardsToDealAnimate.isNotEmpty) {
|
||||
debugPrint("[LOG] dealFromStock: FAILED (Animation already in progress)");
|
||||
return;
|
||||
}
|
||||
if (_draggedCards.isNotEmpty) {
|
||||
debugPrint("[LOG] dealFromStock: FAILED (A card drag is in progress)");
|
||||
return;
|
||||
}
|
||||
|
||||
final bool hasEmptyPile = _currentState.tableau.any((pile) => pile.isEmpty);
|
||||
debugPrint("[LOG] dealFromStock: Checking for empty piles... Result: $hasEmptyPile");
|
||||
|
||||
if (hasEmptyPile) {
|
||||
debugPrint("[LOG] dealFromStock: FAILED (Empty pile found)");
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint("[LOG] dealFromStock: All checks passed. Saving undo state.");
|
||||
_saveUndoState();
|
||||
|
||||
final int cardsToDealCount = min(10, _currentState.stock.length);
|
||||
debugPrint("[LOG] dealFromStock: Preparing ${cardsToDealCount} cards for animation.");
|
||||
|
||||
for (int i = 0; i < cardsToDealCount; i++) {
|
||||
_cardsToDealAnimate.add(_currentState.stock.removeLast());
|
||||
}
|
||||
|
||||
debugPrint("[LOG] dealFromStock: Cards moved to _cardsToDealAnimate queue (Total: ${_cardsToDealAnimate.length}). Notifying listeners...");
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 🔽 덱 분배 애니메이션이 끝난 후 UI가 호출
|
||||
void finalizeDealFromStock(List<SpiderCard> dealtCards) {
|
||||
debugPrint("[LOG] finalizeDealFromStock: CALLED. Finalizing ${dealtCards.length} cards.");
|
||||
|
||||
for (int i = 0; i < dealtCards.length; i++) {
|
||||
final card = dealtCards[i];
|
||||
card.isFaceUp = true;
|
||||
_currentState.tableau[i].add(card);
|
||||
}
|
||||
|
||||
_currentState = _currentState.copyWith(moves: _currentState.moves + 1);
|
||||
|
||||
debugPrint("[LOG] finalizeDealFromStock: FINISHED. Calling _checkCompletedStacks...");
|
||||
_checkCompletedStacks();
|
||||
}
|
||||
|
||||
// ( onDragStarted, onDragCancelled, onCardsDropped, _moveCards 는 동일 )
|
||||
void onDragStarted(List<SpiderCard> cards) {
|
||||
_draggedCards = cards;
|
||||
for (var card in cards) { card.isBeingDragged = true; }
|
||||
notifyListeners();
|
||||
}
|
||||
void onDragCancelled() {
|
||||
for (var card in _draggedCards) { card.isBeingDragged = false; }
|
||||
_draggedCards = [];
|
||||
notifyListeners();
|
||||
}
|
||||
void onCardsDropped(List<SpiderCard> cards, int targetPileIndex) {
|
||||
final int sourcePileIndex = _findPileIndexForCard(cards.first);
|
||||
for (var card in cards) { card.isBeingDragged = false; }
|
||||
_draggedCards = [];
|
||||
_moveCards(cards, sourcePileIndex, targetPileIndex);
|
||||
}
|
||||
void _moveCards(List<SpiderCard> cards, int fromIndex, int toIndex) {
|
||||
if (fromIndex == toIndex) {
|
||||
notifyListeners(); return;
|
||||
}
|
||||
_saveUndoState();
|
||||
final sourcePile = _currentState.tableau[fromIndex];
|
||||
sourcePile.removeRange(sourcePile.length - cards.length, sourcePile.length);
|
||||
if (sourcePile.isNotEmpty && !sourcePile.last.isFaceUp) {
|
||||
sourcePile.last.isFaceUp = true;
|
||||
}
|
||||
final targetPile = _currentState.tableau[toIndex];
|
||||
targetPile.addAll(cards);
|
||||
_currentState = _currentState.copyWith(moves: _currentState.moves + 1);
|
||||
_checkCompletedStacks();
|
||||
}
|
||||
|
||||
int undo() {
|
||||
if (!canUndo) return _undoCount;
|
||||
|
||||
final prevState = _undoHistory.removeLast();
|
||||
_currentState = SpiderGameState.fromHistory(prevState);
|
||||
_undoCount++;
|
||||
notifyListeners();
|
||||
return _undoCount;
|
||||
}
|
||||
|
||||
// ( canPickUpCard, getDraggableStack, isValidMove 는 동일 )
|
||||
bool canPickUpCard(SpiderCard card) {
|
||||
for (final pile in _currentState.tableau) {
|
||||
if (pile.isNotEmpty && pile.last == card) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
List<SpiderCard> getDraggableStack(SpiderCard tappedCard) {
|
||||
final int pileIndex = _findPileIndexForCard(tappedCard);
|
||||
if (pileIndex == -1) return [];
|
||||
final pile = _currentState.tableau[pileIndex];
|
||||
final int cardIndex = pile.indexOf(tappedCard);
|
||||
if (cardIndex == -1 || !tappedCard.isFaceUp) return [];
|
||||
final List<SpiderCard> draggableStack = [tappedCard];
|
||||
for (int i = cardIndex + 1; i < pile.length; i++) {
|
||||
final prevCard = pile[i - 1];
|
||||
final currentCard = pile[i];
|
||||
if (currentCard.isFaceUp &&
|
||||
prevCard.rank == currentCard.rank + 1 &&
|
||||
prevCard.suit == currentCard.suit)
|
||||
{
|
||||
draggableStack.add(currentCard);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return draggableStack;
|
||||
}
|
||||
bool isValidMove(List<SpiderCard> cardsToMove, int targetPileIndex) {
|
||||
if (cardsToMove.isEmpty) return false;
|
||||
final targetPile = _currentState.tableau[targetPileIndex];
|
||||
if (targetPile.isEmpty) return true;
|
||||
final SpiderCard topCardToMove = cardsToMove.first;
|
||||
final SpiderCard targetTopCard = targetPile.last;
|
||||
return topCardToMove.rank == targetTopCard.rank - 1;
|
||||
}
|
||||
|
||||
/// 🔽 _checkCompletedStacks (애니메이션 트리거)
|
||||
void _checkCompletedStacks() {
|
||||
// 🔽 [수정] 애니메이션이 실행 중이면 중복 검사 방지
|
||||
if (_cardsToAnimateStack.isNotEmpty) return;
|
||||
|
||||
bool stackCompleted = false;
|
||||
for (int i = 0; i < _currentState.tableau.length; i++) {
|
||||
final pile = _currentState.tableau[i];
|
||||
if (pile.length < 13) continue;
|
||||
|
||||
final List<SpiderCard> last13Cards = pile.sublist(pile.length - 13);
|
||||
bool isComplete = true;
|
||||
final SpiderSuit targetSuit = last13Cards.first.suit;
|
||||
for (int j = 0; j < 13; j++) {
|
||||
final card = last13Cards[j];
|
||||
if (!card.isFaceUp || card.suit != targetSuit || card.rank != (13 - j)) {
|
||||
isComplete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isComplete) {
|
||||
// 🔽 [수정] 컨트롤러의 큐에만 추가 (인덱스 저장)
|
||||
_cardsToAnimateStack = last13Cards;
|
||||
_animationSourcePileIndex = i;
|
||||
_animationTargetFoundationIndex = _currentState.foundation.length;
|
||||
|
||||
stackCompleted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (stackCompleted) {
|
||||
notifyListeners(); // 👈 UI에 애니메이션을 그리라고 알림
|
||||
} else {
|
||||
_checkGameCompletion();
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔽 [수정] 스택 완성 애니메이션이 끝난 후 UI가 호출 (인자 받도록 변경)
|
||||
void finalizeStackCompletion(List<SpiderCard> cardsToAnimate, int sourceIndex) {
|
||||
debugPrint("[LOG] finalizeStackCompletion: CALLED. Source Index: $sourceIndex");
|
||||
|
||||
// 🔽 [수정] 크래시 방지
|
||||
if (sourceIndex < 0 || sourceIndex >= _currentState.tableau.length) {
|
||||
debugPrint("[LOG] finalizeStackCompletion: FAILED. Invalid Source Index: $sourceIndex");
|
||||
return;
|
||||
}
|
||||
|
||||
_currentState.foundation.add(cardsToAnimate);
|
||||
final pile = _currentState.tableau[sourceIndex];
|
||||
|
||||
if (pile.length >= cardsToAnimate.length) {
|
||||
pile.removeRange(pile.length - cardsToAnimate.length, pile.length);
|
||||
} else {
|
||||
debugPrint("[LOG] finalizeStackCompletion: WARNING. Pile length was ${pile.length}, expected >= ${cardsToAnimate.length}.");
|
||||
}
|
||||
|
||||
if (pile.isNotEmpty && !pile.last.isFaceUp) {
|
||||
pile.last.isFaceUp = true;
|
||||
}
|
||||
|
||||
// 🔽 [삭제] 인덱스 리셋 불필요 (지역 변수로 처리됨)
|
||||
// _animationSourcePileIndex = -1;
|
||||
// _animationTargetFoundationIndex = -1;
|
||||
|
||||
_checkGameCompletion(); // 👈 [핵심] 게임 완료 검사
|
||||
}
|
||||
|
||||
// 🔽 [복원됨]
|
||||
void clearStackAnimationTrigger() {
|
||||
debugPrint("[LOG] clearStackAnimationTrigger: CALLED. Clearing animation queue (Current count: ${_cardsToAnimateStack.length}).");
|
||||
_cardsToAnimateStack.clear();
|
||||
}
|
||||
|
||||
void _checkGameCompletion() {
|
||||
if (_currentState.foundation.length == 8 && !_isGameCompleted) {
|
||||
_isGameCompleted = true;
|
||||
stopTimer();
|
||||
debugPrint("게임 완료! 이동: ${_currentState.moves}, 시간: $_secondsElapsed");
|
||||
notifyListeners(); // 👈 [수정] 게임이 '완료'되었을 때만 notify
|
||||
} else if (!_isGameCompleted) {
|
||||
// 🔽 [수정] 게임이 완료되지 '않았을' 때도 notify (카드 이동 등을 반영하기 위해)
|
||||
notifyListeners();
|
||||
}
|
||||
// (게임이 완료된 후에는 더 이상 notify하지 않음)
|
||||
}
|
||||
|
||||
// ( _saveUndoState, _findPileIndexForCard, submitRank, dispose 는 동일 )
|
||||
void _saveUndoState() {
|
||||
_undoHistory.add(SpiderGameHistory.fromState(_currentState));
|
||||
if (_undoHistory.length > 20) {
|
||||
_undoHistory.removeAt(0);
|
||||
}
|
||||
}
|
||||
int _findPileIndexForCard(SpiderCard card) {
|
||||
return _currentState.tableau.indexWhere((pile) => pile.contains(card));
|
||||
}
|
||||
Future<RankSubmissionResult> submitRank(String playerName) async {
|
||||
final puzzleService = PuzzleService();
|
||||
final identityService = IdentityService();
|
||||
final rankDto = UnifiedRankDto(
|
||||
userId: userId,
|
||||
gameType: 'SPIDER',
|
||||
contextId: difficulty.contextId,
|
||||
playerName: playerName,
|
||||
primaryScore: _currentState.moves,
|
||||
secondaryScore: _secondsElapsed,
|
||||
);
|
||||
final result = await puzzleService.submitRank(rankDto);
|
||||
await identityService.saveUserName(playerName);
|
||||
final int currentMaxLevel = await identityService.getMaxUnlockedLevel(gameType: 'SPIDER');
|
||||
if (currentMaxLevel < 99) {
|
||||
if (difficulty.levelIndex >= currentMaxLevel) {
|
||||
int nextLevel = difficulty.levelIndex + 1;
|
||||
if (nextLevel > SpiderDifficulties.allDifficulties.length) {
|
||||
await identityService.saveMaxUnlockedLevel(99, gameType: 'SPIDER');
|
||||
} else {
|
||||
await identityService.saveMaxUnlockedLevel(nextLevel, gameType: 'SPIDER');
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
extension GameStateCopyWith on SpiderGameState {
|
||||
SpiderGameState copyWith({
|
||||
List<List<SpiderCard>>? tableau,
|
||||
List<SpiderCard>? stock,
|
||||
List<List<SpiderCard>>? foundation,
|
||||
int? moves,
|
||||
}) {
|
||||
return SpiderGameState(
|
||||
tableau: tableau ?? this.tableau,
|
||||
stock: stock ?? this.stock,
|
||||
foundation: foundation ?? this.foundation,
|
||||
moves: moves ?? this.moves,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// packages/feature_game_spider/lib/feature_game_spider.dart
|
||||
|
||||
// 스파이더 앱의 '로비 화면(메인)'
|
||||
export 'screens/spider_lobby_screen.dart';
|
||||
|
||||
// 로비에서 호출할 '게임 플레이 화면'
|
||||
export 'screens/spider_game_screen.dart';
|
||||
@@ -0,0 +1,76 @@
|
||||
// packages/feature_game_spider/lib/models/spider_card.dart
|
||||
|
||||
/// 카드의 4가지 무늬
|
||||
enum SpiderSuit {
|
||||
spade, // ♠️
|
||||
heart, // ♥️
|
||||
club, // ♣️
|
||||
diamond // ♦️
|
||||
}
|
||||
|
||||
/// 스파이더 카드 1장의 데이터 모델
|
||||
class SpiderCard {
|
||||
/// 카드의 고유 ID (Draggable 위젯의 Key로 사용)
|
||||
final int id;
|
||||
|
||||
/// 무늬 (spade, heart 등)
|
||||
final SpiderSuit suit;
|
||||
|
||||
/// 숫자 (1 = A, 11 = J, 12 = Q, 13 = K)
|
||||
final int rank;
|
||||
|
||||
/// 현재 앞면이 보이는지 여부
|
||||
bool isFaceUp;
|
||||
|
||||
/// [UI용] 카드가 현재 드래그 중인지 여부
|
||||
bool isBeingDragged;
|
||||
|
||||
SpiderCard({
|
||||
required this.id,
|
||||
required this.suit,
|
||||
required this.rank,
|
||||
this.isFaceUp = false,
|
||||
this.isBeingDragged = false,
|
||||
});
|
||||
|
||||
/// 카드가 빨간색(하트, 다이아)인지 확인
|
||||
bool get isRed => suit == SpiderSuit.heart || suit == SpiderSuit.diamond;
|
||||
|
||||
/// 랭크를 텍스트(A, K, Q, J, 10...)로 변환
|
||||
String get rankText {
|
||||
switch (rank) {
|
||||
case 1: return 'A';
|
||||
case 11: return 'J';
|
||||
case 12: return 'Q';
|
||||
case 13: return 'K';
|
||||
default: return rank.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// 무늬를 심볼(♠️, ♥️...)로 변환
|
||||
String get suitSymbol {
|
||||
switch (suit) {
|
||||
case SpiderSuit.spade: return '♠️';
|
||||
case SpiderSuit.heart: return '♥️';
|
||||
case SpiderSuit.club: return '♣️';
|
||||
case SpiderSuit.diamond: return '♦️';
|
||||
}
|
||||
}
|
||||
|
||||
// 객체 비교를 위한 == 및 hashCode 오버라이드
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SpiderCard &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id; // 고유 ID로만 비교
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
||||
/// 디버깅용
|
||||
@override
|
||||
String toString() {
|
||||
return '$rankText-$suitSymbol ($id)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// packages/feature_game_spider/lib/models/spider_difficulty.dart
|
||||
|
||||
import 'package:service_api/service_api.dart'; // 👈 공통 GameDifficulty 모델
|
||||
|
||||
/// 스파이더 게임의 난이도 정의
|
||||
class SpiderDifficulty extends GameDifficulty {
|
||||
/// 레벨 순서 (1-9)
|
||||
final int levelIndex;
|
||||
|
||||
/// 무늬 수 (1, 2, 4)
|
||||
final int numSuits;
|
||||
|
||||
/// 카드 분배 문자열 (예: "4,3")
|
||||
final String numCardsDistribution;
|
||||
|
||||
const SpiderDifficulty({
|
||||
required this.levelIndex,
|
||||
required super.name,
|
||||
required super.contextId,
|
||||
required this.numSuits,
|
||||
required this.numCardsDistribution,
|
||||
});
|
||||
}
|
||||
|
||||
/// 앱 전역에서 사용할 스파이더 난이도 목록 (총 9개)
|
||||
/// (스도쿠의 AppLevels와 동일한 구조)
|
||||
class SpiderDifficulties {
|
||||
static final List<SpiderDifficulty> allDifficulties = [
|
||||
// --- 1 Suit (Easy) ---
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 1,
|
||||
name: '입문 (1 Suit)',
|
||||
contextId: 'SPIDER_L1_1SUIT_4-3',
|
||||
numSuits: 1,
|
||||
numCardsDistribution: '4,3',
|
||||
),
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 2,
|
||||
name: '초급 (1 Suit)',
|
||||
contextId: 'SPIDER_L2_1SUIT_5-4',
|
||||
numSuits: 1,
|
||||
numCardsDistribution: '5,4',
|
||||
),
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 3,
|
||||
name: '중급 (1 Suit)',
|
||||
contextId: 'SPIDER_L3_1SUIT_6-5',
|
||||
numSuits: 1,
|
||||
numCardsDistribution: '6,5',
|
||||
),
|
||||
|
||||
// --- 2 Suits (Medium) ---
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 4,
|
||||
name: '상급 (2 Suits)',
|
||||
contextId: 'SPIDER_L4_2SUITS_5-4',
|
||||
numSuits: 2,
|
||||
numCardsDistribution: '5,4',
|
||||
),
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 5,
|
||||
name: '전문가 (2 Suits)',
|
||||
contextId: 'SPIDER_L5_2SUITS_6-5',
|
||||
numSuits: 2,
|
||||
numCardsDistribution: '6,5',
|
||||
),
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 6,
|
||||
name: '마스터 (2 Suits)',
|
||||
contextId: 'SPIDER_L6_2SUITS_7-6',
|
||||
numSuits: 2,
|
||||
numCardsDistribution: '7,6',
|
||||
),
|
||||
|
||||
// --- 4 Suits (Hard) ---
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 7,
|
||||
name: '최상급 (4 Suits)',
|
||||
contextId: 'SPIDER_L7_4SUITS_6-5',
|
||||
numSuits: 4,
|
||||
numCardsDistribution: '6,5',
|
||||
),
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 8,
|
||||
name: '지옥 (4 Suits)',
|
||||
contextId: 'SPIDER_L8_4SUITS_7-6',
|
||||
numSuits: 4,
|
||||
numCardsDistribution: '7,6',
|
||||
),
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 9,
|
||||
name: '챔피언 (4 Suits)',
|
||||
contextId: 'SPIDER_L9_4SUITS_8-7',
|
||||
numSuits: 4,
|
||||
numCardsDistribution: '8,7',
|
||||
),
|
||||
];
|
||||
|
||||
/// 레벨 인덱스(1-9)로 레벨 정보 찾기
|
||||
static SpiderDifficulty getLevel(int levelIndex) {
|
||||
if (levelIndex < 1) levelIndex = 1;
|
||||
if (levelIndex > allDifficulties.length) levelIndex = allDifficulties.length;
|
||||
return allDifficulties.firstWhere((level) => level.levelIndex == levelIndex,
|
||||
orElse: () => allDifficulties[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// 랭킹 화면용 맵 (ContextId -> 이름)
|
||||
static Map<String, String> get contextIdToNameMap {
|
||||
return { for (var level in allDifficulties) level.contextId : level.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// packages/feature_game_spider/lib/models/spider_game_state.dart
|
||||
import 'spider_card.dart';
|
||||
|
||||
/// 게임 보드 전체의 상태를 저장하는 클래스
|
||||
class SpiderGameState {
|
||||
final List<List<SpiderCard>> tableau;
|
||||
final List<SpiderCard> stock;
|
||||
final List<List<SpiderCard>> foundation;
|
||||
final int moves;
|
||||
// ❌ undoCount가 여기서 제거됨
|
||||
|
||||
SpiderGameState({
|
||||
required this.tableau,
|
||||
required this.stock,
|
||||
required this.foundation,
|
||||
required this.moves,
|
||||
});
|
||||
|
||||
/// `spider.html`의 `undoHistory`에 해당하는
|
||||
/// 되돌리기용 복사본을 생성하는 팩토리 생성자
|
||||
factory SpiderGameState.fromHistory(SpiderGameHistory history) {
|
||||
return SpiderGameState(
|
||||
tableau: history.tableau.map((pile) => List.of(pile)).toList(),
|
||||
stock: List.of(history.stock),
|
||||
foundation: history.foundation.map((pile) => List.of(pile)).toList(),
|
||||
moves: history.moves,
|
||||
// ❌ undoCount가 여기서 제거됨
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 되돌리기(Undo)를 위해 저장되는 게임 상태의 스냅샷
|
||||
class SpiderGameHistory {
|
||||
final List<List<SpiderCard>> tableau;
|
||||
final List<SpiderCard> stock;
|
||||
final List<List<SpiderCard>> foundation;
|
||||
final int moves;
|
||||
// ❌ undoCount가 여기서 제거됨
|
||||
|
||||
SpiderGameHistory({
|
||||
required this.tableau,
|
||||
required this.stock,
|
||||
required this.foundation,
|
||||
required this.moves,
|
||||
});
|
||||
|
||||
/// 현재 게임 상태(GameState)로부터 스냅샷 생성
|
||||
factory SpiderGameHistory.fromState(SpiderGameState state) {
|
||||
return SpiderGameHistory(
|
||||
tableau: state.tableau.map((pile) => List.of(pile)).toList(),
|
||||
stock: List.of(state.stock),
|
||||
foundation: state.foundation.map((pile) => List.of(pile)).toList(),
|
||||
moves: state.moves,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
// packages/feature_game_spider/lib/screens/spider_game_screen.dart
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
import '../controllers/spider_game_controller.dart';
|
||||
import '../models/spider_difficulty.dart';
|
||||
import '../models/spider_card.dart';
|
||||
import '../widgets/tableau_pile_widget.dart';
|
||||
import '../widgets/bottom_bar_widget.dart';
|
||||
import '../widgets/card_widget.dart';
|
||||
|
||||
// ❌ [삭제] enum _RankSubmissionStep
|
||||
|
||||
class SpiderGameScreen extends StatefulWidget {
|
||||
const SpiderGameScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SpiderGameScreen> createState() => _SpiderGameScreenState();
|
||||
}
|
||||
|
||||
class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
bool _isDialogShowing = false;
|
||||
final List<GlobalKey> _tableauKeys = List.generate(10, (_) => GlobalKey());
|
||||
final GlobalKey _stockKey = GlobalKey();
|
||||
final GlobalKey _bodyStackKey = GlobalKey();
|
||||
final List<Widget> _animationOverlays = [];
|
||||
bool _showDimOverlay = false;
|
||||
|
||||
VoidCallback? _controllerListener;
|
||||
|
||||
bool _isDealAnimationRunning = false;
|
||||
bool _isStackAnimationRunning = false;
|
||||
|
||||
// ( _buildGameAppBar, _showSurrenderDialog 는 동일 )
|
||||
AppBar _buildGameAppBar(BuildContext context, SpiderGameController controller) {
|
||||
return AppBar(
|
||||
leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()),
|
||||
title: Consumer<SpiderGameController>(
|
||||
builder: (context, controller, child) {
|
||||
final seconds = controller.secondsElapsed;
|
||||
final timeStr = "${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}";
|
||||
return Text(timeStr, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 22));
|
||||
},
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
Consumer<SpiderGameController>(
|
||||
builder: (context, controller, child) {
|
||||
final bool canUndo = controller.canUndo;
|
||||
return IconButton(
|
||||
icon: Icon(Icons.undo, color: canUndo ? null : Colors.grey),
|
||||
onPressed: canUndo ? () {
|
||||
final int currentCount = controller.undo();
|
||||
if (currentCount >= SpiderGameController.maxUndoCount) {
|
||||
_showSurrenderDialog(context);
|
||||
}
|
||||
} : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
Provider.of<SpiderGameController>(context, listen: false).restartGame();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
void _showSurrenderDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('게임 포기'),
|
||||
content: const Text('되돌리기 횟수를 모두 사용했습니다. 게임을 포기하고 로비로 돌아가시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(child: const Text('취소'), onPressed: () => Navigator.of(ctx).pop()),
|
||||
TextButton(
|
||||
child: const Text('포기하기'),
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final controller = Provider.of<SpiderGameController>(context, listen: false);
|
||||
|
||||
_controllerListener = () {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
const double horizontalPadding = 10;
|
||||
const double cardGap = 5;
|
||||
final double cardWidth = (screenSize.width - (horizontalPadding * 2) - (cardGap * 9)) / 10;
|
||||
final double cardHeight = cardWidth * 1.45;
|
||||
|
||||
// 🔽 [수정] 덱 분배 애니메이션 (플래그 가드 추가)
|
||||
if (controller.cardsToDealAnimate.isNotEmpty && !_isDealAnimationRunning) {
|
||||
_isDealAnimationRunning = true; // 👈 [잠금]
|
||||
debugPrint("[LOG] initState Listener: Detected cardsToDealAnimate. Running animation...");
|
||||
_runDealAnimation(controller, cardWidth, cardHeight);
|
||||
}
|
||||
|
||||
// 🔽 [수정] 스택 완성 애니메이션 (경주 조건 해결 로직)
|
||||
if (controller.cardsToAnimateStack.isNotEmpty && !_isStackAnimationRunning) {
|
||||
_isStackAnimationRunning = true; // 👈 [잠금]
|
||||
|
||||
// [핵심] 큐를 복사하고, 인덱스도 *지금* 읽어서 복사합니다.
|
||||
final List<SpiderCard> cardsToAnimate = List.of(controller.cardsToAnimateStack);
|
||||
final int sourceIndex = controller.animationSourcePileIndex;
|
||||
final int targetIndex = controller.animationTargetFoundationIndex;
|
||||
|
||||
// 큐를 즉시 비웁니다.
|
||||
controller.clearStackAnimationTrigger();
|
||||
|
||||
debugPrint("[LOG] initState Listener: Detected cardsToAnimateStack (Source: $sourceIndex). Running animation...");
|
||||
// 복사한 데이터를 인자로 전달합니다.
|
||||
_runStackCompletionAnimation(controller, cardWidth, cardHeight, cardsToAnimate, sourceIndex, targetIndex);
|
||||
}
|
||||
};
|
||||
|
||||
controller.addListener(_controllerListener!);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_controllerListener != null) {
|
||||
final controller = Provider.of<SpiderGameController>(context, listen: false);
|
||||
controller.removeListener(_controllerListener!);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
debugPrint("[LOG] SpiderGameScreen: --- Main Build Method CALLED ---");
|
||||
|
||||
final controller = context.read<SpiderGameController>();
|
||||
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
const double horizontalPadding = 10;
|
||||
const double cardGap = 5;
|
||||
final double cardWidth = (screenSize.width - (horizontalPadding * 2) - (cardGap * 9)) / 10;
|
||||
final double cardHeight = cardWidth * 1.45;
|
||||
final double cardOverlap = cardHeight * 0.4;
|
||||
|
||||
final bool isGameCompleted = context.select((SpiderGameController c) => c.isGameCompleted);
|
||||
if (isGameCompleted && !_isDialogShowing) {
|
||||
_isDialogShowing = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_runGameWinAnimation(context, controller, cardWidth, cardHeight);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: _buildGameAppBar(context, controller),
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: Stack(
|
||||
key: _bodyStackKey,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: const Color(0xFF008000),
|
||||
padding: EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: 10),
|
||||
child: Stack(
|
||||
children: [
|
||||
Consumer<SpiderGameController>(
|
||||
builder: (context, controller, child) {
|
||||
debugPrint("[LOG] Tableau Consumer: Rebuilding");
|
||||
return Stack(
|
||||
children: List.generate(10, (index) {
|
||||
return Positioned(
|
||||
left: index * (cardWidth + cardGap),
|
||||
top: 0,
|
||||
child: TableauPileWidget(
|
||||
key: _tableauKeys[index],
|
||||
pileIndex: index,
|
||||
cards: controller.currentState.tableau[index],
|
||||
cardWidth: cardWidth,
|
||||
cardHeight: cardHeight,
|
||||
cardOverlap: cardOverlap,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const AdBannerWidget(),
|
||||
BottomBarWidget(
|
||||
key: _stockKey,
|
||||
cardWidth: cardWidth,
|
||||
cardHeight: cardHeight,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (_showDimOverlay)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
),
|
||||
|
||||
..._animationOverlays,
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔽 덱 분배 애니메이션 (오버레이)
|
||||
void _runDealAnimation(
|
||||
SpiderGameController controller,
|
||||
double cardWidth,
|
||||
double cardHeight,
|
||||
) {
|
||||
// 🔽 [수정] 덱 분배 애니메이션도 경주 조건을 피하기 위해 인자로 받도록 수정
|
||||
final List<SpiderCard> cardsToDeal = List.of(controller.cardsToDealAnimate);
|
||||
controller.clearDealAnimationTrigger();
|
||||
|
||||
debugPrint("[LOG] _runDealAnimation: Starting. ${cardsToDeal.length} cards.");
|
||||
|
||||
final RenderBox? bodyStackBox = _bodyStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? stockBox = _stockKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (bodyStackBox == null || stockBox == null) {
|
||||
debugPrint("[LOG] _runDealAnimation: FAILED (Keys not ready)");
|
||||
_isDealAnimationRunning = false; // 👈 [잠금 해제]
|
||||
return;
|
||||
}
|
||||
|
||||
final Offset globalStartPos = stockBox.localToGlobal(Offset.zero);
|
||||
final Offset localStartPos = bodyStackBox.globalToLocal(globalStartPos);
|
||||
|
||||
for (int i = 0; i < cardsToDeal.length; i++) {
|
||||
final card = cardsToDeal[i];
|
||||
final RenderBox? targetBox = _tableauKeys[i].currentContext?.findRenderObject() as RenderBox?;
|
||||
if (targetBox == null) continue;
|
||||
|
||||
final Offset globalEndPos = targetBox.localToGlobal(Offset.zero);
|
||||
final double targetY = globalEndPos.dy + controller.currentState.tableau[i].length * (cardHeight * 0.4);
|
||||
final Offset localEndPos = bodyStackBox.globalToLocal(Offset(globalEndPos.dx, targetY));
|
||||
|
||||
final animationDelayMs = i * 100;
|
||||
final animationDurationMs = 600;
|
||||
|
||||
final overlayEntry = TweenAnimationBuilder<double>(
|
||||
key: ValueKey('deal_${card.id}'),
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: Duration(milliseconds: animationDurationMs),
|
||||
builder: (context, value, child) {
|
||||
final currentPos = Offset.lerp(localStartPos, localEndPos, value)!;
|
||||
final bool isFlipping = value > 0.5;
|
||||
final double rotationY = isFlipping ? (value - 0.5) * 2 * pi : 0;
|
||||
|
||||
return Positioned(
|
||||
left: currentPos.dx,
|
||||
top: currentPos.dy,
|
||||
child: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.identity()
|
||||
..setEntry(3, 2, 0.001)
|
||||
..rotateY(rotationY),
|
||||
child: CardWidget(
|
||||
card: card..isFaceUp = (value > 0.5),
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
isDraggable: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Future.delayed(Duration(milliseconds: animationDelayMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.add(overlayEntry);
|
||||
});
|
||||
|
||||
Future.delayed(Duration(milliseconds: animationDurationMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.remove(overlayEntry);
|
||||
});
|
||||
|
||||
if (i == cardsToDeal.length - 1) {
|
||||
debugPrint("[LOG] _runDealAnimation: Animation FINISHED. Calling finalizeDealFromStock.");
|
||||
controller.finalizeDealFromStock(cardsToDeal);
|
||||
_isDealAnimationRunning = false; // 👈 [잠금 해제]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔽 스택 완성 애니메이션 (오버레이)
|
||||
void _runStackCompletionAnimation(
|
||||
SpiderGameController controller,
|
||||
double cardWidth,
|
||||
double cardHeight,
|
||||
// 🔽 [수정] 인자를 받습니다.
|
||||
List<SpiderCard> cardsToAnimate,
|
||||
int sourceIndex,
|
||||
int targetIndex,
|
||||
) {
|
||||
debugPrint("[LOG] _runStackCompletionAnimation: Starting. ${cardsToAnimate.length} cards from index $sourceIndex.");
|
||||
|
||||
final RenderBox? bodyStackBox = _bodyStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? stockBox = _stockKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
// 🔽 [수정] 인자로 받은 sourceIndex 사용
|
||||
final RenderBox? startBox = _tableauKeys[sourceIndex].currentContext?.findRenderObject() as RenderBox?;
|
||||
|
||||
if (bodyStackBox == null || stockBox == null || startBox == null) {
|
||||
debugPrint("[LOG] _runStackCompletionAnimation: FAILED (Keys not ready for index $sourceIndex)");
|
||||
_isStackAnimationRunning = false; // 👈 [잠금 해제]
|
||||
return;
|
||||
}
|
||||
|
||||
final Offset globalStartPos = startBox.localToGlobal(Offset.zero);
|
||||
final Offset localStartPos = bodyStackBox.globalToLocal(globalStartPos);
|
||||
// 🔽 [수정] 스택이 제거되기 '전'의 길이를 기준으로 계산 (정확)
|
||||
final double startY = localStartPos.dy + (controller.currentState.tableau[sourceIndex].length - cardsToAnimate.length) * (cardHeight * 0.4);
|
||||
|
||||
// 🔽 [수정] 인자로 받은 targetIndex 사용
|
||||
final Offset globalEndPos = stockBox.localToGlobal(Offset( (targetIndex * (cardWidth * 0.15)) - cardWidth*3, 10));
|
||||
final Offset localEndPos = bodyStackBox.globalToLocal(globalEndPos);
|
||||
|
||||
for (int i = 0; i < cardsToAnimate.length; i++) {
|
||||
final card = cardsToAnimate[i];
|
||||
final animationDelayMs = i * 80;
|
||||
final animationDurationMs = 400;
|
||||
|
||||
final overlayEntry = TweenAnimationBuilder<double>(
|
||||
key: ValueKey('stack_${card.id}'),
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: Duration(milliseconds: animationDurationMs),
|
||||
builder: (context, value, child) {
|
||||
final currentPos = Offset.lerp(Offset(localStartPos.dx, startY + (i * cardHeight * 0.4)), localEndPos, value)!;
|
||||
return Positioned(
|
||||
left: currentPos.dx,
|
||||
top: currentPos.dy,
|
||||
child: CardWidget(
|
||||
card: card..isFaceUp = true,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
isDraggable: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Future.delayed(Duration(milliseconds: animationDelayMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.add(overlayEntry);
|
||||
});
|
||||
|
||||
Future.delayed(Duration(milliseconds: animationDurationMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.remove(overlayEntry);
|
||||
});
|
||||
if (i == cardsToAnimate.length - 1) {
|
||||
debugPrint("[LOG] _runStackCompletionAnimation: Animation FINISHED. Calling finalizeStackCompletion for index $sourceIndex.");
|
||||
// 🔽 [수정] finalize가 어떤 스택을 처리할지 인덱스를 전달
|
||||
controller.finalizeStackCompletion(cardsToAnimate, sourceIndex);
|
||||
_isStackAnimationRunning = false; // 👈 [잠금 해제]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔽 [수정] _runGameWinAnimation (팝업 호출 로직 변경)
|
||||
void _runGameWinAnimation(
|
||||
BuildContext context,
|
||||
SpiderGameController controller,
|
||||
double cardWidth,
|
||||
double cardHeight,
|
||||
) {
|
||||
final RenderBox? bodyStackBox = _bodyStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (bodyStackBox == null) return;
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final random = Random();
|
||||
final List<SpiderCard> allCards = controller.currentState.foundation.expand((pile) => pile).toList();
|
||||
final Offset globalStartPos = Offset(screenSize.width / 2, screenSize.height * 0.8);
|
||||
final Offset localStartPos = bodyStackBox.globalToLocal(globalStartPos);
|
||||
|
||||
for (int i = 0; i < allCards.length; i++) {
|
||||
final card = allCards[i];
|
||||
final animationDelay = Duration(milliseconds: i * 30);
|
||||
final animationDuration = const Duration(milliseconds: 1500);
|
||||
final Offset globalEndPos = Offset(random.nextDouble() * screenSize.width, -cardHeight - (AppBar().preferredSize.height));
|
||||
final Offset localEndPos = bodyStackBox.globalToLocal(globalEndPos);
|
||||
|
||||
final overlayEntry = TweenAnimationBuilder<double>(
|
||||
key: ValueKey('win_${card.id}'),
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: animationDuration,
|
||||
builder: (context, value, child) {
|
||||
final currentPos = Offset.lerp(localStartPos, localEndPos, value)!;
|
||||
return Positioned(
|
||||
left: currentPos.dx,
|
||||
top: currentPos.dy,
|
||||
child: Transform.rotate(
|
||||
angle: value * pi * 2,
|
||||
child: CardWidget(
|
||||
card: card..isFaceUp=true,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
isDraggable: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Future.delayed(animationDelay, () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.add(overlayEntry);
|
||||
});
|
||||
|
||||
// ❌ [삭제] 500ms 후에 팝업을 띄우는 로직
|
||||
// if (i == 0) { ... }
|
||||
|
||||
Future.delayed(animationDuration, () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.remove(overlayEntry);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 🔽 [추가] 딤 오버레이(배경 어두워짐)는 500ms 뒤에 바로 표시
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted && controller.isGameCompleted) {
|
||||
setState(() { _showDimOverlay = true; });
|
||||
}
|
||||
});
|
||||
|
||||
// 🔽 [추가] 랭킹 팝업은 약 2초 뒤 표시
|
||||
final popupDelay = (allCards.length > 70) ? const Duration(seconds: 2) : const Duration(milliseconds: 500);
|
||||
Future.delayed(popupDelay, () {
|
||||
if (mounted && controller.isGameCompleted) {
|
||||
|
||||
// [수정] _showGameCompletedDialog() 호출 대신 공통 화면으로 이동
|
||||
|
||||
// 1. 점수 포맷터 정의
|
||||
String formatSpiderScore(int primary, int? secondary) {
|
||||
final moves = primary.toString();
|
||||
final time = (secondary ?? 0).toString();
|
||||
return '${moves}회 (${time}초)';
|
||||
}
|
||||
|
||||
// 2. 레벨 저장 콜백 정의
|
||||
Future<void> saveSpiderProgress(String playerName) async {
|
||||
// (playerName은 공통 화면이 IdentityService로 저장)
|
||||
|
||||
final identityService = IdentityService();
|
||||
|
||||
final int currentMaxLevel = await identityService.getMaxUnlockedLevel(gameType: 'SPIDER');
|
||||
if (currentMaxLevel < 99) {
|
||||
if (controller.difficulty.levelIndex >= currentMaxLevel) {
|
||||
int nextLevel = controller.difficulty.levelIndex + 1;
|
||||
if (nextLevel > SpiderDifficulties.allDifficulties.length) {
|
||||
await identityService.saveMaxUnlockedLevel(99, gameType: 'SPIDER');
|
||||
} else {
|
||||
await identityService.saveMaxUnlockedLevel(nextLevel, gameType: 'SPIDER');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 화면 이동
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GameCompletionScreen(
|
||||
args: GameResultArgs(
|
||||
gameType: 'SPIDER',
|
||||
contextId: controller.difficulty.contextId,
|
||||
primaryScore: controller.currentState.moves,
|
||||
secondaryScore: controller.secondsElapsed,
|
||||
userId: controller.userId,
|
||||
userName: controller.userName,
|
||||
scoreFormatter: formatSpiderScore,
|
||||
onProgressSave: saveSpiderProgress,
|
||||
onScreenClose: () {
|
||||
// 공통 화면에서 '닫기'를 누르면 게임 화면도 닫힘
|
||||
if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ [삭제] _showGameCompletedDialog() 메서드 전체 (약 200줄) 삭제
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// packages/feature_game_spider/lib/screens/spider_lobby_screen.dart
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
import 'spider_game_screen.dart';
|
||||
import '../models/spider_difficulty.dart';
|
||||
import '../controllers/spider_game_controller.dart';
|
||||
|
||||
class SpiderLobbyScreen extends StatefulWidget {
|
||||
const SpiderLobbyScreen({ super.key });
|
||||
@override
|
||||
State<SpiderLobbyScreen> createState() => _SpiderLobbyScreenState();
|
||||
}
|
||||
|
||||
class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
|
||||
int _maxUnlockedLevel = 1;
|
||||
Map<int, (int, int)> _rankHistory = {};
|
||||
String? _userName;
|
||||
bool _isLoading = false;
|
||||
|
||||
final PuzzleService _puzzleService = PuzzleService();
|
||||
final IdentityService _identityService = IdentityService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProgress();
|
||||
}
|
||||
|
||||
// ( _loadProgress 메서드는 이전과 동일 )
|
||||
Future<void> _loadProgress() async {
|
||||
final maxLevel = await _identityService.getMaxUnlockedLevel(gameType: 'SPIDER');
|
||||
final String? myName = await _identityService.getSavedUserName();
|
||||
if (mounted) { setState(() { _maxUnlockedLevel = maxLevel; _userName = myName; }); }
|
||||
if (myName == null) return;
|
||||
try {
|
||||
final Map<int, int> oldRankMap = await _identityService.getLastSavedRankMap(gameType: 'SPIDER');
|
||||
List<Future<List<GameRankDto>>> rankFutures = [];
|
||||
for (final level in SpiderDifficulties.allDifficulties) {
|
||||
rankFutures.add(_puzzleService.fetchRanks('SPIDER', level.contextId));
|
||||
}
|
||||
final List<List<GameRankDto>> allRankResults = await Future.wait(rankFutures);
|
||||
Map<int, int> newRankMapForStorage = {};
|
||||
Map<int, (int, int)> newRankHistoryForState = {};
|
||||
for (int i = 0; i < SpiderDifficulties.allDifficulties.length; i++) {
|
||||
final level = SpiderDifficulties.allDifficulties[i];
|
||||
final currentRanks = allRankResults[i];
|
||||
final int levelIndex = level.levelIndex;
|
||||
final int oldRank = oldRankMap[levelIndex] ?? 0;
|
||||
int currentRank = 0;
|
||||
int myRankIndex = currentRanks.indexWhere((r) => r.playerName == myName);
|
||||
if (myRankIndex != -1) { currentRank = myRankIndex + 1; }
|
||||
newRankMapForStorage[levelIndex] = currentRank;
|
||||
newRankHistoryForState[levelIndex] = (oldRank, currentRank);
|
||||
}
|
||||
await _identityService.saveLastRankMap(newRankMapForStorage, gameType: 'SPIDER');
|
||||
if (mounted) { setState(() { _rankHistory = newRankHistoryForState; }); }
|
||||
log("스파이더 랭킹 변동 확인 완료. (유저: $myName)");
|
||||
} catch (e) {
|
||||
log("SpiderLobbyScreen: 랭킹 확인 실패: $e");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 🔽 [수정] _startGame 메서드 (UserInfo 주입)
|
||||
Future<void> _startGame(SpiderDifficulty level) async {
|
||||
setState(() { _isLoading = true; });
|
||||
|
||||
// 1. [수정] 랭킹 등록에 필요한 정보 미리 로드
|
||||
final String userId = await _identityService.getOrCreateUserId();
|
||||
final String? userName = _userName; // (이미 _loadProgress에서 로드됨)
|
||||
|
||||
// 2. 컨트롤러 생성 및 새 게임 시작
|
||||
final gameController = SpiderGameController();
|
||||
gameController.setUserInfo(userId, userName); // 👈 유저 정보 주입
|
||||
gameController.startNewGame(level);
|
||||
|
||||
setState(() { _isLoading = false; });
|
||||
if (!mounted) return;
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
ChangeNotifierProvider.value(
|
||||
value: gameController,
|
||||
child: const SpiderGameScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
_loadProgress();
|
||||
}
|
||||
|
||||
// ( build 메서드는 이전과 동일 )
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
context.watch<ThemeNotifier>();
|
||||
final bool allLevelsUnlocked = _maxUnlockedLevel >= 9;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return CommonGameShell(
|
||||
title: '스파이더 솔리테어',
|
||||
onRankingPressed: () {
|
||||
final List<GameDifficulty> spiderDifficulties = SpiderDifficulties.allDifficulties
|
||||
.map((level) => GameDifficulty(
|
||||
name: level.name,
|
||||
contextId: level.contextId,
|
||||
))
|
||||
.toList();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RankingScreen(
|
||||
gameType: 'SPIDER',
|
||||
difficulties: spiderDifficulties,
|
||||
initialDifficultyName: SpiderDifficulties.getLevel(_maxUnlockedLevel).name,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
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: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: SpiderDifficulties.allDifficulties.length,
|
||||
itemBuilder: (context, index) {
|
||||
final SpiderDifficulty level = SpiderDifficulties.allDifficulties[index];
|
||||
final bool isUnlocked = allLevelsUnlocked || level.levelIndex <= _maxUnlockedLevel;
|
||||
final (int oldRank, int currentRank) = _rankHistory[level.levelIndex] ?? (0, 0);
|
||||
Widget? trailingWidget = isUnlocked ? const Icon(Icons.play_arrow_rounded) : null;
|
||||
String? subtitleText;
|
||||
Color? subtitleColor;
|
||||
if (currentRank > 0) {
|
||||
String rankStr = "${currentRank}위";
|
||||
if (oldRank > 0) {
|
||||
int change = oldRank - currentRank;
|
||||
if (change > 0) {
|
||||
subtitleText = "$rankStr (▲ $change)";
|
||||
subtitleColor = Colors.green;
|
||||
trailingWidget = const Icon(Icons.arrow_circle_up_rounded, color: Colors.green, size: 28);
|
||||
} else if (change < 0) {
|
||||
subtitleText = "$rankStr (▼ ${change.abs()})";
|
||||
subtitleColor = Colors.red;
|
||||
trailingWidget = const Icon(Icons.arrow_circle_down_rounded, color: Colors.red, size: 28);
|
||||
} else {
|
||||
subtitleText = "$rankStr (유지)";
|
||||
subtitleColor = Colors.grey;
|
||||
trailingWidget = const Icon(Icons.check_circle_outline_rounded, color: Colors.grey, size: 28);
|
||||
}
|
||||
} else {
|
||||
subtitleText = "$rankStr (신규 진입)";
|
||||
subtitleColor = Colors.blue;
|
||||
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
|
||||
}
|
||||
} else {
|
||||
if (oldRank > 0) {
|
||||
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
|
||||
subtitleColor = Colors.orange;
|
||||
trailingWidget = const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 28);
|
||||
}
|
||||
}
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
isUnlocked ? Icons.lock_open_rounded : Icons.lock_rounded,
|
||||
color: isUnlocked ? theme.primaryColor : Colors.grey,
|
||||
),
|
||||
title: Text(level.name, style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: isUnlocked ? FontWeight.bold : FontWeight.normal,
|
||||
color: isUnlocked ? theme.textTheme.bodyLarge?.color : Colors.grey,
|
||||
)),
|
||||
subtitle: subtitleText != null
|
||||
? Text(subtitleText, style: TextStyle(color: subtitleColor, fontWeight: FontWeight.bold))
|
||||
: null,
|
||||
trailing: trailingWidget,
|
||||
onTap: isUnlocked && !_isLoading
|
||||
? () => _startGame(level)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// packages/feature_game_spider/lib/widgets/bottom_bar_widget.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/spider_card.dart';
|
||||
import '../controllers/spider_game_controller.dart';
|
||||
import 'card_widget.dart';
|
||||
|
||||
class BottomBarWidget extends StatelessWidget {
|
||||
final double cardWidth;
|
||||
final double cardHeight;
|
||||
|
||||
const BottomBarWidget({
|
||||
super.key, // 👈 GameScreen에서 _stockKey가 전달됨
|
||||
required this.cardWidth,
|
||||
required this.cardHeight,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = Provider.of<SpiderGameController>(context);
|
||||
final state = controller.currentState;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
height: cardHeight + 20,
|
||||
color: theme.bottomAppBarTheme.color,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 1. 파운데이션 (왼쪽)
|
||||
_buildFoundationPiles(context, state.foundation),
|
||||
|
||||
// 2. 이동 횟수
|
||||
Text(
|
||||
"이동: ${state.moves}",
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
|
||||
// 3. 스톡 (오른쪽)
|
||||
// 🔽 [수정] _buildStockPile에 key 전달
|
||||
_buildStockPile(context, controller, state.stock, key),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🔽 [수정] Key? key 파라미터 추가
|
||||
Widget _buildStockPile(BuildContext context, SpiderGameController controller, List<SpiderCard> stock, Key? key) {
|
||||
return GestureDetector(
|
||||
key: key, // 👈 [수정] GameScreen에서 전달받은 _stockKey를 여기에 할당
|
||||
onTap: (){
|
||||
debugPrint("[LOG] BottomBarWidget: Stock pile tapped!");
|
||||
controller.dealFromStock();
|
||||
},
|
||||
child: Container(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).primaryColor,
|
||||
border: Border.all(color: Colors.white54, width: 0.5),
|
||||
borderRadius: BorderRadius.circular(cardWidth * 0.08),
|
||||
),
|
||||
child: (stock.isEmpty)
|
||||
? Center(child: Icon(Icons.block, color: Colors.white.withOpacity(0.5), size: cardWidth * 0.5))
|
||||
: Center(
|
||||
child: Text(
|
||||
"${(stock.length / 10).ceil()}",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: cardWidth * 0.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [Shadow(blurRadius: 2, color: Colors.black.withOpacity(0.5))]
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🔽 [수정] key 파라미터 제거 (Foundation은 위치 계산이 필요 없음)
|
||||
Widget _buildFoundationPiles(BuildContext context, List<List<SpiderCard>> foundation) {
|
||||
return SizedBox(
|
||||
// key: foundationKey, (제거)
|
||||
width: (cardWidth * 0.7) * 4 + cardWidth,
|
||||
height: cardHeight,
|
||||
child: Stack(
|
||||
children: List.generate(8, (index) {
|
||||
return Positioned(
|
||||
left: index * (cardWidth * 0.15),
|
||||
child: Container(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.white54, width: 0.5),
|
||||
borderRadius: BorderRadius.circular(cardWidth * 0.08),
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
),
|
||||
child: (foundation.length > index && foundation[index].isNotEmpty)
|
||||
? CardWidget(
|
||||
card: foundation[index].last..isFaceUp=true,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
isDraggable: false, // 👈 [추가]
|
||||
)
|
||||
: Center(child: Icon(Icons.diamond_outlined, color: Colors.white.withOpacity(0.3), size: cardWidth * 0.3)),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// packages/feature_game_spider/lib/widgets/card_widget.dart
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/spider_card.dart';
|
||||
import '../controllers/spider_game_controller.dart';
|
||||
|
||||
class CardWidget extends StatelessWidget {
|
||||
final SpiderCard card;
|
||||
final double width;
|
||||
final double height;
|
||||
final bool isDraggable; // 👈 [추가]
|
||||
|
||||
const CardWidget({
|
||||
super.key,
|
||||
required this.card,
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.isDraggable = true, // 👈 [추가] 기본값은 true
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 🔽 [수정] isDraggable이 false이면, 드래그 기능 없이 카드 앞면만 즉시 반환
|
||||
if (!isDraggable) {
|
||||
return _buildCardFace(context, card);
|
||||
}
|
||||
|
||||
// --- (isDraggable이 true일 때만 아래 로직 실행) ---
|
||||
final controller = Provider.of<SpiderGameController>(context, listen: false);
|
||||
|
||||
final List<SpiderCard> draggableStack = controller.getDraggableStack(card);
|
||||
final bool canDrag = draggableStack.isNotEmpty;
|
||||
|
||||
return Draggable<List<SpiderCard>>(
|
||||
data: draggableStack,
|
||||
|
||||
// 🔽 [수정] 겹침 높이 계산을 0.4로 수정
|
||||
feedback: Opacity(
|
||||
opacity: 0.8,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height + (draggableStack.length - 1) * (height * 0.4), // 👈 0.22 -> 0.4
|
||||
child: Stack(
|
||||
children: List.generate(draggableStack.length, (index) {
|
||||
return Positioned(
|
||||
top: index * (height * 0.4), // 👈 0.22 -> 0.4
|
||||
left: 0,
|
||||
// 🔽 [수정] 여기는 CardWidget이 아닌 _buildCardFace를 직접 호출 (중첩 Draggable 방지)
|
||||
child: _buildCardFace(context, draggableStack[index]),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
childWhenDragging: _buildCardPlaceholder(context),
|
||||
child: (card.isBeingDragged)
|
||||
? _buildCardPlaceholder(context)
|
||||
: _buildCardFace(context, card),
|
||||
|
||||
onDragStarted: () {
|
||||
if (canDrag) {
|
||||
controller.onDragStarted(draggableStack);
|
||||
}
|
||||
},
|
||||
onDraggableCanceled: (velocity, offset) {
|
||||
controller.onDragCancelled();
|
||||
},
|
||||
onDragEnd: (details) {
|
||||
if (controller.draggedCards.isNotEmpty) {
|
||||
controller.onDragCancelled();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ( _buildCardFace, _buildRankText, _buildCenterSymbols 는 이전과 동일 )
|
||||
Widget _buildCardFace(BuildContext context, SpiderCard card) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: card.isFaceUp ? Colors.white : Theme.of(context).primaryColor,
|
||||
border: Border.all(color: Colors.black54, width: 0.5),
|
||||
borderRadius: BorderRadius.circular(width * 0.08),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(1, 1),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: card.isFaceUp
|
||||
? Stack(
|
||||
children: [
|
||||
_buildRankText(card, Alignment.topLeft),
|
||||
_buildRankText(card, Alignment.bottomRight),
|
||||
_buildCenterSymbols(card),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
Widget _buildRankText(SpiderCard card, Alignment alignment) {
|
||||
final bool isTopLeft = alignment == Alignment.topLeft;
|
||||
final double fontSize = width * 0.4;
|
||||
final double padding = width * 0.05;
|
||||
Widget content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
card.rankText,
|
||||
style: TextStyle(
|
||||
color: card.isRed ? Colors.red : Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
card.suitSymbol,
|
||||
style: TextStyle(
|
||||
color: card.isRed ? Colors.red : Colors.black,
|
||||
fontSize: fontSize * 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!isTopLeft) {
|
||||
content = Transform.rotate(
|
||||
angle: math.pi,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
return Positioned(
|
||||
top: isTopLeft ? padding : null,
|
||||
left: isTopLeft ? padding : null,
|
||||
bottom: isTopLeft ? null : padding,
|
||||
right: isTopLeft ? null : padding,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
Widget _buildCenterSymbols(SpiderCard card) {
|
||||
final double symbolSize = width * 0.2;
|
||||
final double bigSymbolSize = width * 0.7;
|
||||
if (card.rank > 10) {
|
||||
return Center(
|
||||
child: Text(
|
||||
card.suitSymbol,
|
||||
style: TextStyle(
|
||||
color: card.isRed ? Colors.red : Colors.black,
|
||||
fontSize: bigSymbolSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (card.rank == 1) {
|
||||
return Center(
|
||||
child: Text(
|
||||
card.suitSymbol,
|
||||
style: TextStyle(
|
||||
color: card.isRed ? Colors.red : Colors.black,
|
||||
fontSize: bigSymbolSize * 0.8,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
List<Widget> symbols = [];
|
||||
Widget symbol(Alignment align) {
|
||||
return Align(
|
||||
alignment: align,
|
||||
child: Text(
|
||||
card.suitSymbol,
|
||||
style: TextStyle(
|
||||
color: card.isRed ? Colors.red : Colors.black,
|
||||
fontSize: symbolSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget flippedSymbol(Alignment align) {
|
||||
return Align(
|
||||
alignment: align,
|
||||
child: Transform.rotate(
|
||||
angle: math.pi,
|
||||
child: Text(
|
||||
card.suitSymbol,
|
||||
style: TextStyle(
|
||||
color: card.isRed ? Colors.red : Colors.black,
|
||||
fontSize: symbolSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
switch (card.rank) {
|
||||
case 2:
|
||||
symbols.add(symbol(Alignment.topCenter));
|
||||
symbols.add(flippedSymbol(Alignment.bottomCenter));
|
||||
break;
|
||||
case 3:
|
||||
symbols.add(symbol(Alignment.topCenter));
|
||||
symbols.add(symbol(Alignment.center));
|
||||
symbols.add(flippedSymbol(Alignment.bottomCenter));
|
||||
break;
|
||||
case 4:
|
||||
symbols.add(symbol(Alignment.topLeft));
|
||||
symbols.add(symbol(Alignment.topRight));
|
||||
symbols.add(flippedSymbol(Alignment.bottomLeft));
|
||||
symbols.add(flippedSymbol(Alignment.bottomRight));
|
||||
break;
|
||||
case 5:
|
||||
symbols.addAll([
|
||||
symbol(Alignment.topLeft),
|
||||
symbol(Alignment.topRight),
|
||||
symbol(Alignment.center),
|
||||
flippedSymbol(Alignment.bottomLeft),
|
||||
flippedSymbol(Alignment.bottomRight),
|
||||
]);
|
||||
break;
|
||||
case 6:
|
||||
symbols.addAll([
|
||||
symbol(Alignment.topLeft),
|
||||
symbol(Alignment.topRight),
|
||||
symbol(Alignment.centerLeft),
|
||||
symbol(Alignment.centerRight),
|
||||
flippedSymbol(Alignment.bottomLeft),
|
||||
flippedSymbol(Alignment.bottomRight),
|
||||
]);
|
||||
break;
|
||||
case 7:
|
||||
symbols.addAll([
|
||||
symbol(Alignment.topLeft),
|
||||
symbol(Alignment.topRight),
|
||||
symbol(const Alignment(0.0, -0.25)),
|
||||
symbol(Alignment.centerLeft),
|
||||
symbol(Alignment.centerRight),
|
||||
flippedSymbol(Alignment.bottomLeft),
|
||||
flippedSymbol(Alignment.bottomRight),
|
||||
]);
|
||||
break;
|
||||
case 8:
|
||||
symbols.addAll([
|
||||
symbol(Alignment.topLeft),
|
||||
symbol(Alignment.topRight),
|
||||
symbol(const Alignment(0.0, -0.25)),
|
||||
symbol(Alignment.centerLeft),
|
||||
symbol(Alignment.centerRight),
|
||||
flippedSymbol(Alignment.bottomLeft),
|
||||
flippedSymbol(Alignment.bottomRight),
|
||||
flippedSymbol(const Alignment(0.0, 0.25)),
|
||||
]);
|
||||
break;
|
||||
case 9:
|
||||
symbols.addAll([
|
||||
symbol(const Alignment(-0.8, -0.6)),
|
||||
symbol(const Alignment(0.8, -0.6)),
|
||||
symbol(const Alignment(-0.8, 0.0)),
|
||||
symbol(const Alignment(0.8, 0.0)),
|
||||
symbol(Alignment.center),
|
||||
flippedSymbol(const Alignment(-0.8, 0.6)),
|
||||
flippedSymbol(const Alignment(0.8, 0.6)),
|
||||
symbol(const Alignment(0.0, -0.8)),
|
||||
flippedSymbol(const Alignment(0.0, 0.8)),
|
||||
]);
|
||||
break;
|
||||
case 10:
|
||||
symbols.addAll([
|
||||
symbol(const Alignment(-0.8, -0.7)),
|
||||
symbol(const Alignment(0.8, -0.7)),
|
||||
symbol(const Alignment(-0.8, -0.1)),
|
||||
symbol(const Alignment(0.8, -0.1)),
|
||||
symbol(const Alignment(0.0, -0.9)),
|
||||
symbol(const Alignment(0.0, -0.4)),
|
||||
flippedSymbol(const Alignment(-0.8, 0.7)),
|
||||
flippedSymbol(const Alignment(0.8, 0.7)),
|
||||
flippedSymbol(const Alignment(0.0, 0.9)),
|
||||
flippedSymbol(const Alignment(0.0, 0.4)),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: width * 0.2, vertical: height * 0.15),
|
||||
child: Stack(children: symbols),
|
||||
);
|
||||
}
|
||||
Widget _buildCardPlaceholder(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).scaffoldBackgroundColor.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(width * 0.08),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// packages/feature_game_spider/lib/widgets/tableau_pile_widget.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/spider_card.dart';
|
||||
import '../controllers/spider_game_controller.dart';
|
||||
import 'card_widget.dart';
|
||||
|
||||
class TableauPileWidget extends StatelessWidget {
|
||||
final int pileIndex;
|
||||
final List<SpiderCard> cards;
|
||||
final double cardWidth;
|
||||
final double cardHeight;
|
||||
final double cardOverlap;
|
||||
|
||||
const TableauPileWidget({
|
||||
super.key,
|
||||
required this.pileIndex,
|
||||
required this.cards,
|
||||
required this.cardWidth,
|
||||
required this.cardHeight,
|
||||
required this.cardOverlap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 🔽 [수정] 'context.watch'를 사용하여 컨트롤러의 애니메이션 상태를 실시간으로 감지
|
||||
final controller = context.watch<SpiderGameController>();
|
||||
|
||||
return DragTarget<List<SpiderCard>>(
|
||||
onWillAccept: (draggedCards) {
|
||||
if (draggedCards == null) return false;
|
||||
|
||||
if (controller.cardsToDealAnimate.isNotEmpty ||
|
||||
controller.cardsToAnimateStack.isNotEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return controller.isValidMove(draggedCards, pileIndex);
|
||||
},
|
||||
onAccept: (draggedCards) {
|
||||
controller.onCardsDropped(draggedCards, pileIndex);
|
||||
},
|
||||
builder: (context, candidateData, rejectedData) {
|
||||
|
||||
final bool isAnimating = controller.cardsToDealAnimate.isNotEmpty ||
|
||||
controller.cardsToAnimateStack.isNotEmpty;
|
||||
final bool isHighlighted = candidateData.isNotEmpty && !isAnimating;
|
||||
|
||||
// 🔽 [로그 추가] "초록 선"의 원인을 추적합니다.
|
||||
// (하이라이트되거나, 애니메이션 중이거나, 드래그가 감지되면 로그 출력)
|
||||
if (candidateData.isNotEmpty || isAnimating || isHighlighted) {
|
||||
debugPrint("[LOG] TableauPileWidget (Pile $pileIndex): "
|
||||
"candidateData.isNotEmpty = ${candidateData.isNotEmpty}, "
|
||||
"isAnimating = $isAnimating, "
|
||||
"==> isHighlighted = $isHighlighted");
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: cardWidth,
|
||||
constraints: BoxConstraints(
|
||||
minHeight: cardHeight,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isHighlighted
|
||||
? Colors.green.withOpacity(0.3)
|
||||
: (cards.isEmpty ? Colors.black.withOpacity(0.1) : null),
|
||||
borderRadius: BorderRadius.circular(cardWidth * 0.08),
|
||||
),
|
||||
child: Stack(
|
||||
children: List.generate(cards.length, (index) {
|
||||
final card = cards[index];
|
||||
return Positioned(
|
||||
top: index * cardOverlap,
|
||||
left: 0,
|
||||
child: CardWidget(
|
||||
card: card,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# packages/feature_game_spider/pubspec.yaml
|
||||
|
||||
name: feature_game_spider
|
||||
description: The Spider Solitaire game feature, using WebView and local assets.
|
||||
publish_to: 'none'
|
||||
resolution: workspace
|
||||
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: '^3.9.2' # (루트와 동일하게)
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# [C] 공통 서비스
|
||||
service_api:
|
||||
path: ../service_api
|
||||
|
||||
# [A] 공통 UI 셸
|
||||
feature_common:
|
||||
path: ../feature_common
|
||||
|
||||
# 상태 관리
|
||||
provider: ^6.0.0
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
# 🔽 [추가] 로컬 HTML/CSS/JS 파일을 앱에 포함
|
||||
flutter:
|
||||
assets:
|
||||
- assets/spider_game/
|
||||
# (CSS/이미지 등 하위 폴더가 있다면 그것도 명시)
|
||||
- assets/spider_game/css/images/
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:feature_game_spider/feature_game_spider.dart';
|
||||
|
||||
void main() {
|
||||
test('adds one to input values', () {
|
||||
final calculator = Calculator();
|
||||
expect(calculator.addOne(2), 3);
|
||||
expect(calculator.addOne(-7), -6);
|
||||
expect(calculator.addOne(0), 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
/coverage/
|
||||
@@ -0,0 +1,10 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: package
|
||||
@@ -0,0 +1,3 @@
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
@@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
||||
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
This README describes the package. If you publish this package to pub.dev,
|
||||
this README's contents appear on the landing page for your package.
|
||||
|
||||
For information about how to write a good package README, see the guide for
|
||||
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
|
||||
|
||||
For general information about developing packages, see the Dart guide for
|
||||
[creating packages](https://dart.dev/guides/libraries/create-packages)
|
||||
and the Flutter guide for
|
||||
[developing packages and plugins](https://flutter.dev/to/develop-packages).
|
||||
-->
|
||||
|
||||
TODO: Put a short description of the package here that helps potential users
|
||||
know whether this package might be useful for them.
|
||||
|
||||
## Features
|
||||
|
||||
TODO: List what your package can do. Maybe include images, gifs, or videos.
|
||||
|
||||
## Getting started
|
||||
|
||||
TODO: List prerequisites and provide or point to information on how to
|
||||
start using the package.
|
||||
|
||||
## Usage
|
||||
|
||||
TODO: Include short and useful examples for package users. Add longer examples
|
||||
to `/example` folder.
|
||||
|
||||
```dart
|
||||
const like = 'sample';
|
||||
```
|
||||
|
||||
## Additional information
|
||||
|
||||
TODO: Tell users more about the package: where to find more information, how to
|
||||
contribute to the package, how to file issues, what response they can expect
|
||||
from the package authors, and more.
|
||||
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,6 @@
|
||||
// packages/feature_game_sudoku/lib/feature_game_sudoku.dart
|
||||
|
||||
// app_sudoku가 IntroScreen의 다음 화면으로 사용할 '로비 화면'
|
||||
export 'screens/sudoku_lobby_screen.dart';
|
||||
|
||||
// (GameScreen 등은 로비 화면만 알면 되므로 굳이 export 안 해도 됨)
|
||||
@@ -0,0 +1,89 @@
|
||||
// packages/feature_game_sudoku/lib/models/game_level.dart
|
||||
// (이 파일은 service_api에서 이동해 옴)
|
||||
|
||||
class GameLevel {
|
||||
final int levelIndex; // 1-11
|
||||
final String name; // "입문 (4x4)"
|
||||
final int blockSize; // 2, 3, 4
|
||||
final int generatorLevel; // 서버에 요청할 생성기 난이도 (1~5)
|
||||
final String contextId; // 랭킹 ID "SUDOKU_4x4_L1"
|
||||
|
||||
final bool isSequentialNumbers;
|
||||
final bool isSequentialLetters;
|
||||
|
||||
const GameLevel({
|
||||
required this.levelIndex,
|
||||
required this.name,
|
||||
required this.blockSize,
|
||||
required this.generatorLevel,
|
||||
required this.contextId,
|
||||
this.isSequentialNumbers = false,
|
||||
this.isSequentialLetters = false,
|
||||
});
|
||||
}
|
||||
|
||||
class AppLevels {
|
||||
static final List<GameLevel> allLevels = [
|
||||
// --- 2x2 (blockSize = 2) ---
|
||||
const GameLevel(
|
||||
levelIndex: 1, name: "입문 (4x4)", blockSize: 2, generatorLevel: 1,
|
||||
contextId: "SUDOKU_4x4_L1", isSequentialNumbers: true
|
||||
),
|
||||
const GameLevel(
|
||||
levelIndex: 2, name: "초급 (4x4)", blockSize: 2, generatorLevel: 3,
|
||||
contextId: "SUDOKU_4x4_L3", isSequentialLetters: true
|
||||
),
|
||||
const GameLevel(
|
||||
levelIndex: 3, name: "숙련 (4x4)", blockSize: 2, generatorLevel: 5,
|
||||
contextId: "SUDOKU_4x4_L5"
|
||||
),
|
||||
|
||||
// --- 3x3 (blockSize = 3) ---
|
||||
const GameLevel(
|
||||
levelIndex: 4, name: "쉬움 (9x9)", blockSize: 3, generatorLevel: 1,
|
||||
contextId: "SUDOKU_9x9_L1", isSequentialNumbers: true
|
||||
),
|
||||
const GameLevel(
|
||||
levelIndex: 5, name: "중급 (9x9)", blockSize: 3, generatorLevel: 2,
|
||||
contextId: "SUDOKU_9x9_L2", isSequentialLetters: true
|
||||
),
|
||||
const GameLevel(
|
||||
levelIndex: 6, name: "상급 (9x9)", blockSize: 3, generatorLevel: 3,
|
||||
contextId: "SUDOKU_9x9_L3"
|
||||
),
|
||||
const GameLevel(
|
||||
levelIndex: 7, name: "어려움 (9x9)", blockSize: 3, generatorLevel: 4,
|
||||
contextId: "SUDOKU_9x9_L4"
|
||||
),
|
||||
const GameLevel(
|
||||
levelIndex: 8, name: "최상급 (9x9)", blockSize: 3, generatorLevel: 5,
|
||||
contextId: "SUDOKU_9x9_L5"
|
||||
),
|
||||
|
||||
// --- 4x4 (blockSize = 4) ---
|
||||
const GameLevel(
|
||||
levelIndex: 9, name: "전문가 (16x16)", blockSize: 4, generatorLevel: 1,
|
||||
contextId: "SUDOKU_16x16_L1", isSequentialNumbers: true
|
||||
),
|
||||
const GameLevel(
|
||||
levelIndex: 10, name: "마스터 (16x16)", blockSize: 4, generatorLevel: 3,
|
||||
contextId: "SUDOKU_16x16_L3", isSequentialLetters: true
|
||||
),
|
||||
const GameLevel(
|
||||
levelIndex: 11, name: "지옥 (16x16)", blockSize: 4, generatorLevel: 5,
|
||||
contextId: "SUDOKU_16x16_L5"
|
||||
),
|
||||
];
|
||||
|
||||
static GameLevel getLevel(int levelIndex) {
|
||||
if (levelIndex < 1) levelIndex = 1;
|
||||
if (levelIndex > allLevels.length) levelIndex = allLevels.length;
|
||||
return allLevels.firstWhere((level) => level.levelIndex == levelIndex,
|
||||
orElse: () => allLevels[0]
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, String> get contextIdToNameMap {
|
||||
return { for (var level in allLevels) level.contextId : level.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
// packages/feature_game_sudoku/lib/screens/game_screen.dart
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
// [C] 서비스 import
|
||||
import 'package:service_api/service_api.dart';
|
||||
|
||||
// [A] 공통 위젯 import
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
|
||||
// [B] 같은 패키지 내의 위젯 import
|
||||
import '../widgets/number_pad.dart';
|
||||
import '../widgets/sudoku_board.dart';
|
||||
import '../models/game_level.dart';
|
||||
|
||||
// ❌ [삭제] enum _RankSubmissionStep
|
||||
|
||||
class GameScreen extends StatefulWidget {
|
||||
final SudokuGameDto gameData;
|
||||
final String themeName;
|
||||
final String userId;
|
||||
final String? userName;
|
||||
final int levelIndex;
|
||||
|
||||
const GameScreen({
|
||||
super.key,
|
||||
required this.gameData,
|
||||
required this.themeName,
|
||||
required this.userId,
|
||||
required this.userName,
|
||||
required this.levelIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GameScreen> createState() => _GameScreenState();
|
||||
}
|
||||
|
||||
class _GameScreenState extends State<GameScreen> {
|
||||
final PuzzleService _puzzleService = PuzzleService();
|
||||
final IdentityService _identityService = IdentityService();
|
||||
|
||||
late final GameLevel currentLevel;
|
||||
late final int blockSize;
|
||||
late final int gridSize;
|
||||
late final SudokuTheme activeTheme;
|
||||
|
||||
late List<int> puzzleCells;
|
||||
late List<int> solutionCells;
|
||||
late List<int> originalCells;
|
||||
|
||||
int? selectedIndex;
|
||||
int score = 5;
|
||||
int secondsElapsed = 0;
|
||||
Timer? timer;
|
||||
int? selectedNumberPad;
|
||||
Set<int> incorrectCells = {};
|
||||
bool isValidating = false;
|
||||
|
||||
// ❌ [삭제] 랭킹 다이얼로그 전용 상태 변수
|
||||
// _RankSubmissionStep _rankStep = _RankSubmissionStep.enterName;
|
||||
// List<GameRankDto> _rankingList = [];
|
||||
// GameRankWithRankNumber? _myRankResult;
|
||||
// String _submittedPlayerName = "";
|
||||
|
||||
late final TransformationController _transformationController;
|
||||
|
||||
// ... ( _charToInt, _intToChar, initState, dispose, startTimer, onCellTapped, _checkIfBoardIsFull ... )
|
||||
// ... ( onNumberTapped, onUndoTapped, onHintTapped, _onRestartGameTapped, _onQuitGameTapped, _resetBoardZoom ... )
|
||||
// ... ( 이 함수들은 모두 동일합니다 )
|
||||
int _charToInt(String char) {
|
||||
if (char == '0') return 0;
|
||||
if (char.codeUnitAt(0) >= '1'.codeUnitAt(0) && char.codeUnitAt(0) <= '9'.codeUnitAt(0)) {
|
||||
return int.parse(char);
|
||||
}
|
||||
if (char.codeUnitAt(0) >= 'A'.codeUnitAt(0) && char.codeUnitAt(0) <= 'Z'.codeUnitAt(0)) {
|
||||
return char.codeUnitAt(0) - 'A'.codeUnitAt(0) + 10;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
String _intToChar(int num) {
|
||||
if (num == 0) return '0';
|
||||
if (num >= 1 && num <= 9) return num.toString();
|
||||
if (num >= 10 && num <= 35) return String.fromCharCode('A'.codeUnitAt(0) + (num - 10));
|
||||
return '?';
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
currentLevel = AppLevels.getLevel(widget.levelIndex);
|
||||
blockSize = currentLevel.blockSize;
|
||||
gridSize = blockSize * blockSize;
|
||||
|
||||
_transformationController = TransformationController();
|
||||
|
||||
String themeForThisGame = widget.themeName;
|
||||
bool isEasyMode = currentLevel.isSequentialNumbers || currentLevel.isSequentialLetters;
|
||||
|
||||
if (currentLevel.isSequentialNumbers) {
|
||||
themeForThisGame = AppThemes.numbers;
|
||||
} else if (currentLevel.isSequentialLetters) {
|
||||
themeForThisGame = AppThemes.letters;
|
||||
}
|
||||
|
||||
activeTheme = AppThemes.buildGameTheme(
|
||||
themeForThisGame,
|
||||
gridSize,
|
||||
isEasyMode: isEasyMode,
|
||||
);
|
||||
|
||||
puzzleCells = widget.gameData.question.split('').map(_charToInt).toList();
|
||||
solutionCells = widget.gameData.solution.split('').map(_charToInt).toList();
|
||||
originalCells = widget.gameData.question.split('').map(_charToInt).toList();
|
||||
|
||||
startTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timer?.cancel();
|
||||
_transformationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void startTimer() {
|
||||
timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
setState(() {
|
||||
secondsElapsed++;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void onCellTapped(int index) {
|
||||
if (originalCells[index] == 0) {
|
||||
|
||||
if (incorrectCells.isNotEmpty && !incorrectCells.contains(index)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('틀린 값을 먼저 수정해주세요. (되돌리기 ↩)'),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
selectedIndex = index;
|
||||
|
||||
if (selectedNumberPad != null) {
|
||||
final int numberValue = selectedNumberPad!;
|
||||
puzzleCells[index] = numberValue;
|
||||
|
||||
if (numberValue != solutionCells[index]) {
|
||||
if (!incorrectCells.contains(index)) {
|
||||
if (score > 0) {
|
||||
score--;
|
||||
}
|
||||
incorrectCells.add(index);
|
||||
}
|
||||
} else {
|
||||
incorrectCells.remove(index);
|
||||
}
|
||||
|
||||
_checkIfBoardIsFull();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _checkIfBoardIsFull() {
|
||||
if (!puzzleCells.contains(0) && !isValidating) {
|
||||
_validateGame();
|
||||
}
|
||||
}
|
||||
|
||||
void onNumberTapped(int numberValue) {
|
||||
setState(() {
|
||||
if (selectedNumberPad == numberValue) {
|
||||
selectedNumberPad = null;
|
||||
} else {
|
||||
selectedNumberPad = numberValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void onUndoTapped() {
|
||||
if (incorrectCells.isNotEmpty) {
|
||||
int errorIndex = incorrectCells.first;
|
||||
setState(() {
|
||||
puzzleCells[errorIndex] = 0;
|
||||
incorrectCells.remove(errorIndex);
|
||||
selectedIndex = errorIndex;
|
||||
});
|
||||
}
|
||||
else if (selectedIndex != null && originalCells[selectedIndex!] == 0) {
|
||||
setState(() {
|
||||
puzzleCells[selectedIndex!] = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void onHintTapped() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('힌트 기능은 준비 중입니다.')),
|
||||
);
|
||||
}
|
||||
|
||||
void _onRestartGameTapped() {
|
||||
setState(() {
|
||||
puzzleCells = originalCells.toList();
|
||||
incorrectCells.clear();
|
||||
selectedIndex = null;
|
||||
selectedNumberPad = null;
|
||||
score = 5;
|
||||
_resetBoardZoom();
|
||||
|
||||
timer?.cancel();
|
||||
secondsElapsed = 0;
|
||||
startTimer();
|
||||
});
|
||||
}
|
||||
|
||||
void _onQuitGameTapped() {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
void _resetBoardZoom() {
|
||||
_transformationController.value = Matrix4.identity();
|
||||
}
|
||||
|
||||
Future<void> _validateGame() async {
|
||||
if (isValidating) return;
|
||||
setState(() { isValidating = true; });
|
||||
|
||||
timer?.cancel();
|
||||
String currentAnswer = puzzleCells.map(_intToChar).join('');
|
||||
|
||||
try {
|
||||
final bool result = await _puzzleService.validateSolution(
|
||||
widget.gameData.puzzleId,
|
||||
currentAnswer,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
if(mounted) {
|
||||
// 🔽 [수정] _showRankingDialog() 호출 대신
|
||||
// 공통 게임 완료 화면(GameCompletionScreen)으로 이동
|
||||
|
||||
// 1. 점수 포맷터 정의
|
||||
String formatSudokuScore(int primary, int? secondary) {
|
||||
final min = (primary ~/ 60).toString().padLeft(2, '0');
|
||||
final sec = (primary % 60).toString().padLeft(2, '0');
|
||||
final time = '$min:$sec';
|
||||
int displayScore = 5 - (secondary ?? 5);
|
||||
return '$time (Score: $displayScore)';
|
||||
}
|
||||
|
||||
// 2. 레벨 저장 콜백 정의
|
||||
Future<void> saveSudokuProgress(String playerName) async {
|
||||
// (playerName은 공통 화면이 IdentityService로 저장하므로 여기선 사용 안 함)
|
||||
|
||||
final int currentMaxLevel = await _identityService.getMaxUnlockedLevel();
|
||||
if (currentMaxLevel < 99) {
|
||||
if (widget.levelIndex >= currentMaxLevel) {
|
||||
int nextLevel = widget.levelIndex + 1;
|
||||
if (nextLevel > AppLevels.allLevels.length) {
|
||||
await _identityService.saveMaxUnlockedLevel(99);
|
||||
} else {
|
||||
await _identityService.saveMaxUnlockedLevel(nextLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 화면 이동
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GameCompletionScreen(
|
||||
args: GameResultArgs(
|
||||
gameType: 'SUDOKU',
|
||||
contextId: currentLevel.contextId,
|
||||
primaryScore: secondsElapsed,
|
||||
secondaryScore: (5 - score),
|
||||
userId: widget.userId,
|
||||
userName: widget.userName,
|
||||
scoreFormatter: formatSudokuScore,
|
||||
onProgressSave: saveSudokuProgress,
|
||||
onScreenClose: () {
|
||||
// 공통 화면에서 '닫기'를 누르면 게임 화면도 닫힘
|
||||
if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if(mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('🤔 틀린 부분이 있습니다.')),
|
||||
);
|
||||
}
|
||||
startTimer();
|
||||
}
|
||||
} catch (e) {
|
||||
if(mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('오류: $e')),
|
||||
);
|
||||
}
|
||||
startTimer();
|
||||
} finally {
|
||||
if(mounted) {
|
||||
setState(() { isValidating = false; });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ [삭제] _showRankingDialog() 메서드 전체 (약 150줄) 삭제
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
context.watch<ThemeNotifier>();
|
||||
|
||||
String formattedTime =
|
||||
'${(secondsElapsed ~/ 60).toString().padLeft(2, '0')}:${(secondsElapsed % 60).toString().padLeft(2, '0')}';
|
||||
|
||||
final Map<int, int> numberCounts = {};
|
||||
for (int i = 1; i <= gridSize; i++) { numberCounts[i] = 0; }
|
||||
for (int cellValue in puzzleCells) {
|
||||
if (cellValue > 0) {
|
||||
numberCounts[cellValue] = (numberCounts[cellValue] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
bool isLandscape = constraints.maxWidth > constraints.maxHeight;
|
||||
if (isLandscape) {
|
||||
return _buildLandscapeLayout(context, numberCounts, constraints, formattedTime);
|
||||
} else {
|
||||
return _buildPortraitLayout(context, numberCounts, constraints, formattedTime);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const AdBannerWidget(), // 👈 [A] feature_common의 AdBannerWidget
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPortraitLayout(BuildContext context, Map<int, int> numberCounts, BoxConstraints constraints, String formattedTime) {
|
||||
final double boardWidth = (constraints.maxWidth > 600) ? 600 : constraints.maxWidth;
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: boardWidth),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0),
|
||||
child: _buildGameInfoWidget(formattedTime),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildSudokuBoardWidget(),
|
||||
const SizedBox(height: 15),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: _buildControlPanelWidget(context, numberCounts, isLandscape: false, boardWidth: boardWidth),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLandscapeLayout(BuildContext context, Map<int, int> numberCounts, BoxConstraints constraints, String formattedTime) {
|
||||
const double infoBarHeight = 60.0;
|
||||
double boardWidth = constraints.maxHeight - infoBarHeight - 32.0;
|
||||
|
||||
double controlPanelWidth;
|
||||
const double numberPadScaleRatio = 0.6;
|
||||
double padWidth = boardWidth * numberPadScaleRatio;
|
||||
|
||||
if (padWidth < 200) padWidth = 200;
|
||||
if (padWidth > 350) padWidth = 350;
|
||||
controlPanelWidth = padWidth + 100;
|
||||
|
||||
double totalWidth = boardWidth + controlPanelWidth + 16.0;
|
||||
if (totalWidth > (constraints.maxWidth - 32.0)) {
|
||||
double scale = (constraints.maxWidth - 32.0) / totalWidth;
|
||||
boardWidth *= scale;
|
||||
controlPanelWidth *= scale;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildGameInfoWidget(formattedTime),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: boardWidth,
|
||||
child: _buildSudokuBoardWidget(),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
SizedBox(
|
||||
width: controlPanelWidth,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildControlPanelWidget(context, numberCounts, isLandscape: true, boardWidth: boardWidth),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGameInfoWidget(String formattedTime) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text('SCORE: $score', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
Text(formattedTime, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSudokuBoardWidget() {
|
||||
return GestureDetector(
|
||||
onLongPress: _resetBoardZoom,
|
||||
child: InteractiveViewer(
|
||||
transformationController: _transformationController,
|
||||
boundaryMargin: const EdgeInsets.all(20.0),
|
||||
minScale: 1.0,
|
||||
maxScale: 2.5,
|
||||
child: SudokuBoard(
|
||||
blockSize: blockSize,
|
||||
theme: activeTheme,
|
||||
cells: puzzleCells,
|
||||
originalCells: originalCells,
|
||||
selectedIndex: selectedIndex,
|
||||
selectedNumberPad: selectedNumberPad,
|
||||
incorrectCells: incorrectCells,
|
||||
onCellTapped: onCellTapped,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControlPanelWidget(BuildContext context, Map<int, int> numberCounts, {required bool isLandscape, required double boardWidth}) {
|
||||
|
||||
final ThemeData themeData = Theme.of(context);
|
||||
|
||||
const double numberPadScaleRatio = 0.6;
|
||||
double? padMaxWidth;
|
||||
|
||||
if (!isLandscape) {
|
||||
padMaxWidth = boardWidth * numberPadScaleRatio;
|
||||
} else {
|
||||
padMaxWidth = boardWidth * numberPadScaleRatio;
|
||||
if (padMaxWidth < 200) padMaxWidth = 200;
|
||||
}
|
||||
|
||||
Widget numberPadGrid = ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: padMaxWidth ?? double.infinity),
|
||||
child: NumberPad(
|
||||
blockSize: blockSize,
|
||||
theme: activeTheme,
|
||||
numberCounts: numberCounts,
|
||||
selectedNumber: selectedNumberPad,
|
||||
onNumberTapped: onNumberTapped,
|
||||
isLandscape: isLandscape,
|
||||
),
|
||||
);
|
||||
|
||||
Widget leftButtons = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, color: themeData.colorScheme.error, size: 30),
|
||||
onPressed: _onQuitGameTapped,
|
||||
tooltip: "게임 종료",
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.refresh, color: themeData.colorScheme.secondary, size: 30),
|
||||
onPressed: _onRestartGameTapped,
|
||||
tooltip: "다시하기",
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget rightButtons = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: onHintTapped,
|
||||
icon: Icon(Icons.lightbulb_outline, color: themeData.colorScheme.secondary, size: 30),
|
||||
tooltip: "힌트",
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onUndoTapped,
|
||||
icon: Icon(Icons.undo, color: themeData.colorScheme.error, size: 30),
|
||||
tooltip: "되돌리기",
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (isLandscape) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
numberPadGrid,
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
leftButtons,
|
||||
rightButtons
|
||||
],
|
||||
)
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
leftButtons,
|
||||
Expanded(child: numberPadGrid),
|
||||
rightButtons,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// packages/feature_game_sudoku/lib/screens/sudoku_lobby_screen.dart
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
// [C] 서비스 import
|
||||
import 'package:service_api/service_api.dart';
|
||||
// [A] 공통 셸(Shell) 위젯 import
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
// [B] 같은 패키지 내의 화면/모델 import
|
||||
import 'game_screen.dart';
|
||||
import '../models/game_level.dart'; // 👈 스도쿠 전용 레벨
|
||||
|
||||
class SudokuLobbyScreen extends StatefulWidget {
|
||||
const SudokuLobbyScreen({ super.key });
|
||||
|
||||
@override
|
||||
State<SudokuLobbyScreen> createState() => _SudokuLobbyScreenState();
|
||||
}
|
||||
|
||||
class _SudokuLobbyScreenState extends State<SudokuLobbyScreen> {
|
||||
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();
|
||||
_selectedThemeName = AppThemes.random;
|
||||
_loadProgress();
|
||||
}
|
||||
|
||||
Future<void> _loadProgress() async {
|
||||
final maxLevel = await _identityService.getMaxUnlockedLevel();
|
||||
final String? myName = await _identityService.getSavedUserName();
|
||||
if (mounted) { setState(() { _maxUnlockedLevel = maxLevel; _userName = myName; }); }
|
||||
if (myName == null) return;
|
||||
try {
|
||||
final Map<int, int> oldRankMap = await _identityService.getLastSavedRankMap();
|
||||
List<Future<List<GameRankDto>>> rankFutures = [];
|
||||
for (final level in AppLevels.allLevels) {
|
||||
rankFutures.add(_puzzleService.fetchRanks('SUDOKU', level.contextId));
|
||||
}
|
||||
final List<List<GameRankDto>> allRankResults = await Future.wait(rankFutures);
|
||||
Map<int, int> newRankMapForStorage = {};
|
||||
Map<int, (int, int)> newRankHistoryForState = {};
|
||||
for (int i = 0; i < AppLevels.allLevels.length; i++) {
|
||||
final level = AppLevels.allLevels[i];
|
||||
final currentRanks = allRankResults[i];
|
||||
final int levelIndex = level.levelIndex;
|
||||
final int oldRank = oldRankMap[levelIndex] ?? 0;
|
||||
int currentRank = 0;
|
||||
int myRankIndex = currentRanks.indexWhere((r) => r.playerName == myName);
|
||||
if (myRankIndex != -1) { currentRank = myRankIndex + 1; }
|
||||
newRankMapForStorage[levelIndex] = currentRank;
|
||||
newRankHistoryForState[levelIndex] = (oldRank, currentRank);
|
||||
}
|
||||
await _identityService.saveLastRankMap(newRankMapForStorage);
|
||||
if (mounted) { setState(() { _rankHistory = newRankHistoryForState; }); }
|
||||
log("모든 레벨 랭킹 변동 확인 완료. (유저: $myName)");
|
||||
} catch (e) {
|
||||
log("SudokuLobbyScreen: 랭킹 확인 실패: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startGame(GameLevel level) async {
|
||||
setState(() { _isLoading = true; });
|
||||
try {
|
||||
final String difficulty = level.levelIndex.toString();
|
||||
final SudokuGameDto gameData = await _puzzleService.startGame(difficulty);
|
||||
final String userId = await _identityService.getOrCreateUserId();
|
||||
final String? userName = _userName;
|
||||
if (mounted) {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GameScreen(
|
||||
gameData: gameData,
|
||||
themeName: _selectedThemeName,
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
levelIndex: level.levelIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
_loadProgress(); // 게임 끝나고 돌아오면 랭킹 새로고침
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('게임 로딩 실패: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() { _isLoading = false; });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
context.watch<ThemeNotifier>(); // 테마 감지
|
||||
final bool allLevelsUnlocked = _maxUnlockedLevel >= 99;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// [A] feature_common의 CommonGameShell을 사용
|
||||
return CommonGameShell(
|
||||
title: '스도쿠 게임', // 셸의 AppBar에 표시될 제목
|
||||
|
||||
// 🔽 [수정] 랭킹 버튼 클릭 시 실행될 함수를 주입
|
||||
onRankingPressed: () {
|
||||
|
||||
// 1. 스도쿠 레벨(AppLevels)을 공통 모델(GameDifficulty)로 변환
|
||||
final List<GameDifficulty> sudokuDifficulties = AppLevels.allLevels
|
||||
.map((level) => GameDifficulty(
|
||||
name: level.name,
|
||||
contextId: level.contextId,
|
||||
))
|
||||
.toList();
|
||||
|
||||
// 2. 공통 랭킹 화면(RankingScreen)에 주입하며 호출
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RankingScreen(
|
||||
gameType: 'SUDOKU', // 👈 이 게임은 스도쿠
|
||||
difficulties: sudokuDifficulties, // 👈 스도쿠 난이도 목록
|
||||
initialDifficultyName: AppLevels.getLevel(_maxUnlockedLevel).name,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
// 🔽 셸의 'body'에 스도쿠 레벨 목록을 전달
|
||||
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
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text("테마: ", style: TextStyle(fontSize: 18)),
|
||||
DropdownButton<String>(
|
||||
value: _selectedThemeName,
|
||||
items: AppThemes.selectableThemeNames.map((themeName) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: themeName,
|
||||
child: Text(themeName, style: const TextStyle(fontSize: 20)),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (themeName) {
|
||||
if (themeName != null) {
|
||||
setState(() { _selectedThemeName = themeName; });
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 레벨 목록 ListView
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: AppLevels.allLevels.length,
|
||||
itemBuilder: (context, index) {
|
||||
final GameLevel level = AppLevels.allLevels[index];
|
||||
final bool isUnlocked = allLevelsUnlocked || level.levelIndex <= _maxUnlockedLevel;
|
||||
final (int oldRank, int currentRank) = _rankHistory[level.levelIndex] ?? (0, 0);
|
||||
Widget? trailingWidget = isUnlocked ? const Icon(Icons.play_arrow_rounded) : null;
|
||||
String? subtitleText;
|
||||
Color? subtitleColor;
|
||||
|
||||
if (currentRank > 0) {
|
||||
String rankStr = "${currentRank}위";
|
||||
if (oldRank > 0) {
|
||||
int change = oldRank - currentRank;
|
||||
if (change > 0) {
|
||||
subtitleText = "$rankStr (▲ $change)";
|
||||
subtitleColor = Colors.green;
|
||||
trailingWidget = const Icon(Icons.arrow_circle_up_rounded, color: Colors.green, size: 28);
|
||||
} else if (change < 0) {
|
||||
subtitleText = "$rankStr (▼ ${change.abs()})";
|
||||
subtitleColor = Colors.red;
|
||||
trailingWidget = const Icon(Icons.arrow_circle_down_rounded, color: Colors.red, size: 28);
|
||||
} else {
|
||||
subtitleText = "$rankStr (유지)";
|
||||
subtitleColor = Colors.grey;
|
||||
trailingWidget = const Icon(Icons.check_circle_outline_rounded, color: Colors.grey, size: 28);
|
||||
}
|
||||
} else {
|
||||
subtitleText = "$rankStr (신규 진입)";
|
||||
subtitleColor = Colors.blue;
|
||||
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
|
||||
}
|
||||
} else {
|
||||
if (oldRank > 0) {
|
||||
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
|
||||
subtitleColor = Colors.orange;
|
||||
trailingWidget = const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 28);
|
||||
}
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
isUnlocked ? Icons.lock_open_rounded : Icons.lock_rounded,
|
||||
color: isUnlocked ? theme.primaryColor : Colors.grey,
|
||||
),
|
||||
title: Text(level.name, style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: isUnlocked ? FontWeight.bold : FontWeight.normal,
|
||||
color: isUnlocked ? theme.textTheme.bodyLarge?.color : Colors.grey,
|
||||
)),
|
||||
subtitle: subtitleText != null
|
||||
? Text(subtitleText, style: TextStyle(color: subtitleColor, fontWeight: FontWeight.bold))
|
||||
: null,
|
||||
trailing: trailingWidget,
|
||||
onTap: isUnlocked && !_isLoading
|
||||
? () => _startGame(level)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// packages/feature_game_sudoku/lib/widgets/number_pad.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:service_api/service_api.dart'; // 👈 SudokuTheme import
|
||||
|
||||
class NumberPad extends StatelessWidget {
|
||||
final int blockSize;
|
||||
final SudokuTheme theme;
|
||||
final Map<int, int> numberCounts;
|
||||
final int? selectedNumber;
|
||||
final Function(int) onNumberTapped;
|
||||
final bool isLandscape;
|
||||
|
||||
const NumberPad({
|
||||
super.key,
|
||||
required this.blockSize,
|
||||
required this.theme,
|
||||
required this.numberCounts,
|
||||
required this.selectedNumber,
|
||||
required this.onNumberTapped,
|
||||
required this.isLandscape,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final int gridSize = blockSize * blockSize;
|
||||
final ThemeData themeData = Theme.of(context);
|
||||
final bool isDark = themeData.brightness == Brightness.dark;
|
||||
|
||||
final Color selectedColor = themeData.primaryColor;
|
||||
final Color onSelectedColor = themeData.colorScheme.onPrimary;
|
||||
|
||||
final Color completedColor = isDark ? Colors.white24 : Colors.black26;
|
||||
final Color completedTextColor = isDark ? Colors.white54 : Colors.black54;
|
||||
|
||||
final Color defaultTextColor = isDark ? Colors.white70 : Colors.black87;
|
||||
|
||||
List<Widget> numberButtons = List.generate(gridSize, (index) {
|
||||
int numberValue = index + 1;
|
||||
String numberSymbol = theme.getSymbol(numberValue);
|
||||
bool isSelected = (numberValue == selectedNumber);
|
||||
bool isCompleted = (numberCounts[numberValue] ?? 0) >= gridSize;
|
||||
|
||||
Widget button = ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isSelected ? selectedColor : null,
|
||||
foregroundColor: isSelected ? onSelectedColor : defaultTextColor,
|
||||
disabledBackgroundColor: completedColor,
|
||||
disabledForegroundColor: completedTextColor,
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
textStyle: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4))
|
||||
),
|
||||
onPressed: isCompleted
|
||||
? null
|
||||
: () => onNumberTapped(numberValue),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: Text(numberSymbol),
|
||||
),
|
||||
);
|
||||
|
||||
if (isLandscape) {
|
||||
return Flexible(child: button);
|
||||
} else {
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
if (isLandscape) {
|
||||
return Wrap(
|
||||
runSpacing: 4.0,
|
||||
spacing: 4.0,
|
||||
children: numberButtons,
|
||||
);
|
||||
} else {
|
||||
return GridView.count(
|
||||
crossAxisCount: blockSize,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
children: numberButtons,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// packages/feature_game_sudoku/lib/widgets/sudoku_board.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:service_api/service_api.dart'; // 👈 SudokuTheme import
|
||||
|
||||
class SudokuBoard extends StatelessWidget {
|
||||
final int blockSize;
|
||||
final SudokuTheme theme;
|
||||
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.theme,
|
||||
required this.cells,
|
||||
required this.originalCells,
|
||||
required this.selectedIndex,
|
||||
required this.selectedNumberPad,
|
||||
required this.incorrectCells,
|
||||
required this.onCellTapped,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final int gridSize = blockSize * blockSize;
|
||||
final double fontSize = (gridSize > 9) ? (gridSize > 16 ? 12 : 16) : 24;
|
||||
|
||||
final ThemeData themeData = Theme.of(context);
|
||||
final ColorScheme colorScheme = themeData.colorScheme;
|
||||
final bool isDark = themeData.brightness == Brightness.dark;
|
||||
|
||||
final Color thickBorderColor = colorScheme.onSurface.withOpacity(isDark ? 0.8 : 1.0);
|
||||
final Color thinBorderColor = themeData.dividerColor;
|
||||
|
||||
final Color incorrectBg = colorScheme.error.withOpacity(0.2);
|
||||
final Color highlightedBg = colorScheme.primary.withOpacity(0.2);
|
||||
final Color editableBg = themeData.scaffoldBackgroundColor;
|
||||
final Color fixedBg = isDark ? colorScheme.surfaceVariant : colorScheme.onSurface.withOpacity(0.1);
|
||||
|
||||
final Color selectedTextColor = colorScheme.secondary;
|
||||
final Color incorrectTextColor = colorScheme.error;
|
||||
final Color editableTextColor = colorScheme.primary;
|
||||
final Color fixedTextColor = colorScheme.onSurface;
|
||||
|
||||
return AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
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 thickBorder = BorderSide(color: thickBorderColor, width: 2.0);
|
||||
BorderSide thinBorder = BorderSide(color: thinBorderColor, width: 0.5);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => onCellTapped(index),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: isIncorrect
|
||||
? incorrectBg
|
||||
: isHighlighted
|
||||
? highlightedBg
|
||||
: isEditable
|
||||
? editableBg
|
||||
: fixedBg,
|
||||
border: Border(
|
||||
top: (row == 0) ? thickBorder : thinBorder,
|
||||
left: (col == 0) ? thickBorder : thinBorder,
|
||||
right: (col == gridSize - 1) ? thickBorder : (col % blockSize == blockSize - 1) ? thickBorder : thinBorder,
|
||||
bottom: (row == gridSize - 1) ? thickBorder : (row % blockSize == blockSize - 1) ? thickBorder : thinBorder,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
cellValue == 0 ? '' : theme.getSymbol(cellValue),
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected
|
||||
? selectedTextColor
|
||||
: isIncorrect
|
||||
? incorrectTextColor
|
||||
: isEditable
|
||||
? editableTextColor
|
||||
: fixedTextColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
name: feature_game_sudoku
|
||||
description: The Sudoku game feature, including the game screen, board, and number pad.
|
||||
version: 1.0.0
|
||||
publish_to: 'none'
|
||||
resolution: workspace
|
||||
environment:
|
||||
sdk: '^3.9.2'
|
||||
flutter: '>=3.10.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# 1. 공통 서비스 로직 (필수)
|
||||
# GameLevel, SudokuGameDto, SudokuTheme, PuzzleService, IdentityService 등을 사용
|
||||
service_api:
|
||||
path: ../service_api
|
||||
|
||||
# 2. UI 및 상태 관리
|
||||
provider: ^6.0.0 # (GameScreen에서 ThemeNotifier를 watch)
|
||||
|
||||
feature_common:
|
||||
path: ../feature_common
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
lints: ^3.0.0
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:feature_game_sudoku/feature_game_sudoku.dart';
|
||||
|
||||
void main() {
|
||||
test('adds one to input values', () {
|
||||
final calculator = Calculator();
|
||||
expect(calculator.addOne(2), 3);
|
||||
expect(calculator.addOne(-7), -6);
|
||||
expect(calculator.addOne(0), 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
/coverage/
|
||||
@@ -0,0 +1,10 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: package
|
||||
@@ -0,0 +1,3 @@
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
@@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
||||
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
This README describes the package. If you publish this package to pub.dev,
|
||||
this README's contents appear on the landing page for your package.
|
||||
|
||||
For information about how to write a good package README, see the guide for
|
||||
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
|
||||
|
||||
For general information about developing packages, see the Dart guide for
|
||||
[creating packages](https://dart.dev/guides/libraries/create-packages)
|
||||
and the Flutter guide for
|
||||
[developing packages and plugins](https://flutter.dev/to/develop-packages).
|
||||
-->
|
||||
|
||||
TODO: Put a short description of the package here that helps potential users
|
||||
know whether this package might be useful for them.
|
||||
|
||||
## Features
|
||||
|
||||
TODO: List what your package can do. Maybe include images, gifs, or videos.
|
||||
|
||||
## Getting started
|
||||
|
||||
TODO: List prerequisites and provide or point to information on how to
|
||||
start using the package.
|
||||
|
||||
## Usage
|
||||
|
||||
TODO: Include short and useful examples for package users. Add longer examples
|
||||
to `/example` folder.
|
||||
|
||||
```dart
|
||||
const like = 'sample';
|
||||
```
|
||||
|
||||
## Additional information
|
||||
|
||||
TODO: Tell users more about the package: where to find more information, how to
|
||||
contribute to the package, how to file issues, what response they can expect
|
||||
from the package authors, and more.
|
||||
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,13 @@
|
||||
// packages/service_api/lib/models/game_difficulty.dart
|
||||
class GameDifficulty {
|
||||
/// 랭킹 Dropdown에 표시될 이름 (예: "중급 (9x9)", "1 Suit (Easy)")
|
||||
final String name;
|
||||
|
||||
/// API 조회 시 사용할 랭킹 ID (예: "SUDOKU_9x9_L2", "1_SUITS_4-3")
|
||||
final String contextId;
|
||||
|
||||
const GameDifficulty({
|
||||
required this.name,
|
||||
required this.contextId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// lib/models/game_level.dart
|
||||
|
||||
// 11단계 레벨의 모든 속성을 정의하는 클래스
|
||||
class GameLevel {
|
||||
final int levelIndex; // 1-11
|
||||
final String name; // "입문 (4x4)"
|
||||
final int blockSize; // 2, 3, 4
|
||||
final int generatorLevel; // 서버에 요청할 생성기 난이도 (1~5)
|
||||
final String contextId; // 랭킹 ID "SUDOKU_4x4_L1"
|
||||
|
||||
// 🔽 [신규] 테마 정책
|
||||
final bool isSequentialNumbers; // L1, L4, L9 (숫자 고정)
|
||||
final bool isSequentialLetters; // L2, L5, L10 (문자 고정)
|
||||
// (둘 다 false이면 HomeScreen에서 선택한 랜덤 테마 사용)
|
||||
|
||||
const GameLevel({
|
||||
required this.levelIndex,
|
||||
required this.name,
|
||||
required this.blockSize,
|
||||
required this.generatorLevel,
|
||||
required this.contextId,
|
||||
this.isSequentialNumbers = false,
|
||||
this.isSequentialLetters = false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// lib/models/game_rank_dto.dart
|
||||
|
||||
class GameRankDto {
|
||||
final String playerName;
|
||||
final int primaryScore; // 시간 (초)
|
||||
final int? secondaryScore; // 점수 (저장된 값, 예: 0~4)
|
||||
|
||||
GameRankDto({
|
||||
required this.playerName,
|
||||
required this.primaryScore,
|
||||
this.secondaryScore
|
||||
});
|
||||
|
||||
factory GameRankDto.fromJson(Map<String, dynamic> json) {
|
||||
return GameRankDto(
|
||||
playerName: json['playerName'],
|
||||
primaryScore: (json['primaryScore'] as num).toInt(),
|
||||
secondaryScore: (json['secondaryScore'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔽 [신규 추가] 나의 랭킹 + 순위(숫자)를 담는 DTO
|
||||
class GameRankWithRankNumber {
|
||||
final GameRankDto rankData;
|
||||
final int rankNumber;
|
||||
|
||||
GameRankWithRankNumber({
|
||||
required this.rankData,
|
||||
required this.rankNumber,
|
||||
});
|
||||
|
||||
factory GameRankWithRankNumber.fromJson(Map<String, dynamic> json) {
|
||||
return GameRankWithRankNumber(
|
||||
rankData: GameRankDto.fromJson(json['rankData']),
|
||||
rankNumber: (json['rankNumber'] as num).toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔽 [신규 추가] 랭킹 등록 시 서버가 반환하는 최종 DTO
|
||||
class RankSubmissionResult {
|
||||
final List<GameRankDto> topRanks; // 상위 10개 랭킹
|
||||
final GameRankWithRankNumber? myRank; // 나의 랭킹 정보 (순위 포함)
|
||||
|
||||
RankSubmissionResult({
|
||||
required this.topRanks,
|
||||
this.myRank,
|
||||
});
|
||||
|
||||
factory RankSubmissionResult.fromJson(Map<String, dynamic> json) {
|
||||
// topRanks 파싱
|
||||
final List<dynamic> topRanksJson = json['topRanks'] ?? [];
|
||||
final List<GameRankDto> topRanksList = topRanksJson
|
||||
.map((item) => GameRankDto.fromJson(item))
|
||||
.toList();
|
||||
|
||||
// myRank 파싱 (null일 수 있음)
|
||||
final Map<String, dynamic>? myRankJson = json['myRank'];
|
||||
final GameRankWithRankNumber? myRankData =
|
||||
myRankJson != null ? GameRankWithRankNumber.fromJson(myRankJson) : null;
|
||||
|
||||
return RankSubmissionResult(
|
||||
topRanks: topRanksList,
|
||||
myRank: myRankData,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// lib/models/sudoku_game_dto.dart
|
||||
|
||||
class SudokuGameDto {
|
||||
final int puzzleId; // 👈 [추가] 서버에서 보낸 ID
|
||||
final String question;
|
||||
final String solution;
|
||||
final int blockSize;
|
||||
final int gridSize;
|
||||
|
||||
SudokuGameDto({
|
||||
required this.puzzleId, // 👈 [추가]
|
||||
required this.question,
|
||||
required this.solution,
|
||||
required this.blockSize,
|
||||
}) : gridSize = blockSize * blockSize;
|
||||
|
||||
factory SudokuGameDto.fromJson(Map<String, dynamic> json) {
|
||||
int bs = json['blockSize'] ?? 3;
|
||||
return SudokuGameDto(
|
||||
puzzleId: json['puzzleId'], // 👈 [추가] 서버의 puzzleId 매핑
|
||||
question: json['question'],
|
||||
solution: json['solution'],
|
||||
blockSize: bs,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// lib/models/sudoku_theme.dart
|
||||
|
||||
// 1. SudokuTheme 클래스
|
||||
// '게임 시작' 시점에 동적으로 생성될 객체입니다.
|
||||
class SudokuTheme {
|
||||
final String name; // "숫자", "알파벳", "과일"
|
||||
final List<String> symbols; // 👈 '게임에 실제 사용할' 무작위로 뽑힌 기호 리스트
|
||||
|
||||
const SudokuTheme({required this.name, required this.symbols});
|
||||
|
||||
// 1-based 정수(1)를 테마 기호("🍎")로 변환
|
||||
String getSymbol(int value) {
|
||||
if (value > 0 && value <= symbols.length) {
|
||||
return symbols[value - 1]; // 1 -> index 0
|
||||
}
|
||||
return '?';
|
||||
}
|
||||
|
||||
// 테마 기호("🍎")를 1-based 정수(1)로 변환
|
||||
int getValue(String symbol) {
|
||||
int index = symbols.indexOf(symbol);
|
||||
if (index != -1) {
|
||||
return index + 1; // index 0 -> 1
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. AppThemes 클래스 (테마 저장소 역할)
|
||||
class AppThemes {
|
||||
|
||||
// --- 테마 이름 정의 ---
|
||||
static const String random = "랜덤";
|
||||
static const String numbers = "숫자";
|
||||
static const String letters = "알파벳";
|
||||
static const String fruits = "과일";
|
||||
static const String korean = "한글";
|
||||
static const String animals = "동물";
|
||||
|
||||
// --- 1. 거대한 '상징 풀' 정의 (25개 이상) ---
|
||||
static const List<String> _numberPool = [
|
||||
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16",
|
||||
"17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"
|
||||
];
|
||||
|
||||
static const List<String> _letterPool = [
|
||||
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P",
|
||||
"Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"
|
||||
];
|
||||
|
||||
static const List<String> _fruitPool = [
|
||||
"🍎", "🍌", "🍇", "🍓", "🍊", "🍋", "🍉", "🍑", "🍒", "🥝", "🥥", "🍍", "🥑", "🍆", "🍅", "🌽",
|
||||
"🥕", "🫑", "🌶️", "🥦", "🥬", "🥒", "🍄", "🥜", "🫘", "🍏", "🍐", "🍈", "🥭", "🫒"
|
||||
];
|
||||
|
||||
static const List<String> _koreanPool = [
|
||||
"가", "나", "다", "라", "마", "바", "사", "아", "자", "차", "카", "타", "파", "하", "고", "노",
|
||||
"도", "로", "모", "보", "소", "오", "조", "초", "코", "구", "누", "두", "루", "무"
|
||||
];
|
||||
|
||||
static const List<String> _animalPool = [
|
||||
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🐔",
|
||||
"🐧", "🐦", "🐤", "🦆", "🦅", "🦉", "🦇", "🐺", "🐗", "🐴", "🦄", "🐝", "🐛", "🦋"
|
||||
];
|
||||
|
||||
// --- 2. 홈 화면 '선택' 메뉴에 표시될 이름 리스트 ---
|
||||
static final List<String> selectableThemeNames = [
|
||||
random,
|
||||
numbers,
|
||||
letters,
|
||||
fruits,
|
||||
korean,
|
||||
animals
|
||||
];
|
||||
|
||||
// --- 3. 테마 이름과 실제 '상징 풀'을 매핑 ---
|
||||
static final Map<String, List<String>> _themePools = {
|
||||
numbers: _numberPool,
|
||||
letters: _letterPool,
|
||||
fruits: _fruitPool,
|
||||
korean: _koreanPool,
|
||||
animals: _animalPool,
|
||||
};
|
||||
|
||||
// --- 4. [핵심] 게임 시작 시 호출될 테마 '빌더' 함수 ---
|
||||
static SudokuTheme buildGameTheme(String themeName, int gridSize, {bool isEasyMode = false}) { // 👈 [수정]
|
||||
String effectiveThemeName = themeName;
|
||||
|
||||
if (themeName == random) {
|
||||
final actualThemes = _themePools.keys.toList();
|
||||
effectiveThemeName = (actualThemes..shuffle()).first;
|
||||
}
|
||||
|
||||
final List<String> pool = _themePools[effectiveThemeName] ?? _numberPool;
|
||||
|
||||
if (pool.length < gridSize) {
|
||||
throw Exception("$effectiveThemeName 테마의 상징이 ${pool.length}개뿐입니다. $gridSize개가 필요합니다.");
|
||||
}
|
||||
|
||||
List<String> selectedSymbols;
|
||||
|
||||
// 🔽 [수정] 'isEasyMode'가 true이면 섞지 않고 순서대로 뽑음
|
||||
if (isEasyMode) {
|
||||
// (예: 4x4 Easy -> 1,2,3,4 또는 A,B,C,D)
|
||||
selectedSymbols = pool.sublist(0, gridSize);
|
||||
} else {
|
||||
// 그 외: 거대 풀을 섞은 뒤, gridSize만큼 뽑음
|
||||
selectedSymbols = (pool.toList()..shuffle()).sublist(0, gridSize);
|
||||
}
|
||||
|
||||
return SudokuTheme(
|
||||
name: effectiveThemeName,
|
||||
symbols: selectedSymbols,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// lib/models/unified_rank_dto.dart
|
||||
|
||||
class UnifiedRankDto {
|
||||
final String userId; // 👈 [수정] 앱-고유 ID
|
||||
final String gameType;
|
||||
final String? contextId;
|
||||
final String playerName;
|
||||
final int primaryScore;
|
||||
final int? secondaryScore;
|
||||
|
||||
UnifiedRankDto({
|
||||
required this.userId, // 👈 [수정] 생성자에 추가
|
||||
required this.gameType,
|
||||
this.contextId,
|
||||
required this.playerName,
|
||||
required this.primaryScore,
|
||||
this.secondaryScore,
|
||||
});
|
||||
|
||||
// Dart 객체를 JSON으로 변환 (서버 전송용)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'userId': userId, // 👈 [수정]
|
||||
'gameType': gameType,
|
||||
'contextId': contextId,
|
||||
'playerName': playerName,
|
||||
'primaryScore': primaryScore,
|
||||
'secondaryScore': secondaryScore,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
class ValidateResultDto {
|
||||
final bool isCorrect;
|
||||
|
||||
ValidateResultDto({required this.isCorrect});
|
||||
|
||||
factory ValidateResultDto.fromJson(Map<String, dynamic> json) {
|
||||
return ValidateResultDto(
|
||||
isCorrect: json['correct'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// packages/service_api/lib/service_api.dart
|
||||
|
||||
// Models
|
||||
export 'models/game_difficulty.dart'; // 👈 [추가]
|
||||
export 'models/game_rank_dto.dart';
|
||||
export 'models/sudoku_game_dto.dart';
|
||||
export 'models/sudoku_theme.dart';
|
||||
export 'models/unified_rank_dto.dart';
|
||||
export 'models/validate_result_dto.dart';
|
||||
// ❌ (game_level.dart는 여기서 삭제)
|
||||
|
||||
// Services
|
||||
export 'services/identity_service.dart';
|
||||
export 'services/puzzle_service.dart';
|
||||
export 'services/theme_notifier.dart';
|
||||
export 'services/session_notifier.dart';
|
||||
@@ -0,0 +1,195 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
// 🔽 [신규] 현재 로그인 세션을 담을 모델
|
||||
class UserSession {
|
||||
final String userId;
|
||||
final String? userName;
|
||||
final String loginProvider; // "guest", "google", "apple"
|
||||
final String? email;
|
||||
|
||||
UserSession({
|
||||
required this.userId,
|
||||
this.userName,
|
||||
this.loginProvider = "guest",
|
||||
this.email,
|
||||
});
|
||||
|
||||
bool get isGuest => loginProvider == "guest";
|
||||
}
|
||||
|
||||
// 앱-고유 ID와 사용자 이름, 레벨 진행 상황을 관리하는 서비스
|
||||
class IdentityService {
|
||||
// --- (모든 키 이름은 동일하게 유지) ---
|
||||
static const String _userIdKey = 'app_user_id';
|
||||
static const String _userNameKey = 'app_user_name';
|
||||
// 🔽 [신규] 로그인 상태 저장을 위한 키
|
||||
static const String _loginProviderKey = 'app_login_provider';
|
||||
static const String _userEmailKey = 'app_user_email';
|
||||
|
||||
static const String _sudokuMaxLevelKey = 'max_unlocked_level';
|
||||
static const String _sudokuRankMapKey = 'last_checked_rank_map';
|
||||
static const String _spiderMaxLevelKey = 'max_unlocked_spider_level';
|
||||
static const String _spiderRankMapKey = 'last_checked_spider_rank_map';
|
||||
|
||||
final _storage = const FlutterSecureStorage();
|
||||
|
||||
/// 🔽 [신규] iOS 앱 간 데이터 공유를 위한 옵션
|
||||
IOSOptions _getIOSOptions() => const IOSOptions(
|
||||
// 🔽 [수정] Xcode 설정 전까지 'groupId'를 주석 처리하여 크래시 방지
|
||||
// groupId: 'group.com.lunaticbum.mygamecenter',
|
||||
);
|
||||
|
||||
AndroidOptions _getAndroidOptions() => const AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
);
|
||||
|
||||
// 🔽 [신규] 1. 현재 세션 정보를 '객체'로 가져오기
|
||||
Future<UserSession> getUserSession() async {
|
||||
final userId = await getOrCreateUserId(); // 게스트 ID는 항상 보장
|
||||
final userName = await getSavedUserName();
|
||||
final loginProvider = await _storage.read(
|
||||
key: _loginProviderKey,
|
||||
iOptions: _getIOSOptions(),
|
||||
aOptions: _getAndroidOptions()
|
||||
) ?? "guest";
|
||||
final email = await _storage.read(
|
||||
key: _userEmailKey,
|
||||
iOptions: _getIOSOptions(),
|
||||
aOptions: _getAndroidOptions()
|
||||
);
|
||||
|
||||
return UserSession(
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
loginProvider: loginProvider,
|
||||
email: email,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 앱-고유 ID 가져오기 (없으면 생성)
|
||||
Future<String> getOrCreateUserId() async {
|
||||
String? userId = await _storage.read(
|
||||
key: _userIdKey,
|
||||
iOptions: _getIOSOptions(),
|
||||
aOptions: _getAndroidOptions(),
|
||||
);
|
||||
|
||||
if (userId == null) {
|
||||
userId = const Uuid().v4();
|
||||
await _storage.write(
|
||||
key: _userIdKey,
|
||||
value: userId,
|
||||
iOptions: _getIOSOptions(),
|
||||
aOptions: _getAndroidOptions(),
|
||||
);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
// 3. 랭킹에 등록한 사용자 이름 가져오기
|
||||
Future<String?> getSavedUserName() async {
|
||||
return await _storage.read(
|
||||
key: _userNameKey,
|
||||
iOptions: _getIOSOptions(),
|
||||
aOptions: _getAndroidOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 랭킹 등록 성공 시, 사용자 이름 저장하기
|
||||
Future<void> saveUserName(String name) async {
|
||||
await _storage.write(
|
||||
key: _userNameKey,
|
||||
value: name,
|
||||
iOptions: _getIOSOptions(),
|
||||
aOptions: _getAndroidOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
// 🔽 [신규] 5. 소셜 로그인 성공 시 호출 (계정 연결)
|
||||
Future<UserSession> saveSocialLogin({
|
||||
required String newUserId, // 서버가 발급한 마스터 계정 ID
|
||||
required String newUserName,
|
||||
required String newEmail,
|
||||
required String provider, // "google" 또는 "apple"
|
||||
}) async {
|
||||
await _storage.write(key: _userIdKey, value: newUserId, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
await _storage.write(key: _userNameKey, value: newUserName, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
await _storage.write(key: _userEmailKey, value: newEmail, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
await _storage.write(key: _loginProviderKey, value: provider, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
|
||||
return UserSession(
|
||||
userId: newUserId,
|
||||
userName: newUserName,
|
||||
loginProvider: provider,
|
||||
email: newEmail,
|
||||
);
|
||||
}
|
||||
|
||||
// 🔽 [신규] 6. 로그아웃 (게스트 계정으로 되돌리기)
|
||||
Future<UserSession> logout() async {
|
||||
// 소셜 로그인 정보만 삭제
|
||||
await _storage.delete(key: _userNameKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
await _storage.delete(key: _userEmailKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
await _storage.delete(key: _loginProviderKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
|
||||
return await getUserSession();
|
||||
}
|
||||
|
||||
// 7. 최대 레벨 가져오기
|
||||
Future<int> getMaxUnlockedLevel({String gameType = 'SUDOKU'}) async {
|
||||
final key = gameType == 'SPIDER' ? _spiderMaxLevelKey : _sudokuMaxLevelKey;
|
||||
String? levelString = await _storage.read(
|
||||
key: key,
|
||||
iOptions: _getIOSOptions(),
|
||||
aOptions: _getAndroidOptions(),
|
||||
);
|
||||
return int.parse(levelString ?? '1'); // 기본값 1
|
||||
}
|
||||
|
||||
// 8. 최대 레벨 저장하기
|
||||
Future<void> saveMaxUnlockedLevel(int level, {String gameType = 'SUDOKU'}) async {
|
||||
final key = gameType == 'SPIDER' ? _spiderMaxLevelKey : _sudokuMaxLevelKey;
|
||||
await _storage.write(
|
||||
key: key,
|
||||
value: level.toString(),
|
||||
iOptions: _getIOSOptions(),
|
||||
aOptions: _getAndroidOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
// 9. 마지막 랭킹 맵 가져오기
|
||||
Future<Map<int, int>> getLastSavedRankMap({String gameType = 'SUDOKU'}) async {
|
||||
final key = gameType == 'SPIDER' ? _spiderRankMapKey : _sudokuRankMapKey;
|
||||
String? jsonString = await _storage.read(
|
||||
key: key,
|
||||
iOptions: _getIOSOptions(),
|
||||
aOptions: _getAndroidOptions(),
|
||||
);
|
||||
|
||||
if (jsonString == null) return {};
|
||||
try {
|
||||
final Map<String, dynamic> decodedMap = jsonDecode(jsonString);
|
||||
return decodedMap.map((key, value) => MapEntry(int.parse(key), value as int));
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// 10. 마지막 랭킹 맵 저장하기
|
||||
Future<void> saveLastRankMap(Map<int, int> rankMap, {String gameType = 'SUDOKU'}) async {
|
||||
final key = gameType == 'SPIDER' ? _spiderRankMapKey : _sudokuRankMapKey;
|
||||
|
||||
final Map<String, int> stringKeyMap =
|
||||
rankMap.map((key, value) => MapEntry(key.toString(), value));
|
||||
|
||||
String jsonString = jsonEncode(stringKeyMap);
|
||||
await _storage.write(
|
||||
key: key,
|
||||
value: jsonString,
|
||||
iOptions: _getIOSOptions(),
|
||||
aOptions: _getAndroidOptions(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:service_api/models/sudoku_game_dto.dart';
|
||||
import 'package:service_api/models/unified_rank_dto.dart';
|
||||
import 'package:service_api/models/game_rank_dto.dart';
|
||||
|
||||
class PuzzleService {
|
||||
final String _baseUrl = "https://lunaticbum.kr";
|
||||
|
||||
// 🔽 [수정] 'difficulty' 파라미터 1개만 받음 (1~11)
|
||||
Future<SudokuGameDto> startGame(String difficulty) async {
|
||||
final response = await http.get(
|
||||
// 🔽 [수정] 'difficulty' 파라미터만 전달
|
||||
Uri.parse('$_baseUrl/puzzle/sudoku/start?difficulty=$difficulty'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
return SudokuGameDto.fromJson(data);
|
||||
} else {
|
||||
throw Exception('게임 로딩 실패: ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
|
||||
// 'puzzleId'를 받아 검증 (서버 DTO와 일치)
|
||||
Future<bool> validateSolution(int puzzleId, String answer) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/puzzle/sudoku/validate'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'puzzleId': puzzleId,
|
||||
'answer': answer,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body)['correct'] ?? false;
|
||||
} else {
|
||||
log("정답 확인 실패: ${response.statusCode}");
|
||||
log("응답 본문: ${response.body}");
|
||||
throw Exception('정답 확인 실패: ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
|
||||
// 🔽 [전체 수정] submitRank 함수
|
||||
// 반환 타입이 Future<RankSubmissionResult>로 변경되었습니다.
|
||||
Future<RankSubmissionResult> submitRank(UnifiedRankDto rankDto) async {
|
||||
|
||||
final requestBody = jsonEncode(rankDto.toJson());
|
||||
log(">>> 랭킹 등록 요청: $requestBody");
|
||||
|
||||
final response = await http.post(
|
||||
// 🔽 [수정] API 경로가 /api/ranks/submit
|
||||
Uri.parse('$_baseUrl/api/ranks/submit'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: requestBody,
|
||||
);
|
||||
|
||||
// 🔽 [수정] 성공(200) 시, 서버가 반환한 RankSubmissionResult 객체를 파싱
|
||||
if (response.statusCode == 200) {
|
||||
log("<<< 랭킹 등록 성공: 200 OK (RankSubmissionResult 반환됨)");
|
||||
try {
|
||||
final Map<String, dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
// 🔽 [수정] RankSubmissionResult.fromJson으로 파싱
|
||||
return RankSubmissionResult.fromJson(data);
|
||||
} catch (e) {
|
||||
log("<<< 랭킹 등록 성공했으나, 반환된 랭킹 목록 파싱 실패: $e");
|
||||
throw Exception('랭킹 목록 파싱 실패: $e');
|
||||
}
|
||||
}
|
||||
// 🔽 [수정] 실패 시, 기존 로직과 동일하게 에러 처리
|
||||
else {
|
||||
log("<<< 랭킹 등록 실패: ${response.statusCode}");
|
||||
try {
|
||||
final errorBody = utf8.decode(response.bodyBytes);
|
||||
log("<<< 서버 에러 메시지: $errorBody");
|
||||
throw Exception(errorBody);
|
||||
} catch (e) {
|
||||
throw Exception('랭킹 등록 실패: ${response.reasonPhrase}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 랭킹 조회
|
||||
Future<List<GameRankDto>> fetchRanks(String gameType, String? contextId) async {
|
||||
final queryParams = {
|
||||
'gameType': gameType,
|
||||
if (contextId != null) 'contextId': contextId,
|
||||
};
|
||||
final uri = Uri.parse('$_baseUrl/api/ranks/list').replace(queryParameters: queryParams);
|
||||
|
||||
final response = await http.get(uri);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
return data.map((json) => GameRankDto.fromJson(json)).toList();
|
||||
} else {
|
||||
throw Exception('랭킹 로딩 실패');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||
import 'identity_service.dart';
|
||||
import 'puzzle_service.dart';
|
||||
|
||||
class SessionNotifier with ChangeNotifier {
|
||||
final IdentityService _identityService = IdentityService();
|
||||
final PuzzleService _puzzleService = PuzzleService();
|
||||
|
||||
UserSession? _session;
|
||||
|
||||
UserSession? get session => _session;
|
||||
bool get isLoading => _session == null;
|
||||
bool get isGuest => _session?.isGuest ?? true;
|
||||
|
||||
// 🔽 [수정] 'GoogleSignIn()' 생성자 대신 '.instance' 싱글톤 사용
|
||||
final GoogleSignIn _googleSignIn = GoogleSignIn.instance;
|
||||
|
||||
SessionNotifier() {
|
||||
loadSession();
|
||||
}
|
||||
|
||||
/// 앱 시작 시 저장된 세션 로드
|
||||
Future<void> loadSession() async {
|
||||
_session = await _identityService.getUserSession();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// (백엔드 연동 후) 로그인/계정 연결
|
||||
Future<void> login(String provider) async {
|
||||
if (isLoading) return;
|
||||
|
||||
final guestUserId = _session!.userId; // 현재 게스트 ID
|
||||
String? idToken;
|
||||
String? email;
|
||||
String? userName;
|
||||
|
||||
try {
|
||||
if (provider == 'google') {
|
||||
// 🔽 [수정] 'signIn()' 메서드 대신 'authenticate()' 사용
|
||||
final GoogleSignInAccount? googleUser = await _googleSignIn.authenticate();
|
||||
if (googleUser == null) return; // 유저가 취소
|
||||
|
||||
final GoogleSignInAuthentication googleAuth = googleUser.authentication;
|
||||
idToken = googleAuth.idToken; // 👈 [핵심] 이 토큰을 백엔드로 전송
|
||||
email = googleUser.email;
|
||||
userName = googleUser.displayName;
|
||||
|
||||
} else if (provider == 'apple') {
|
||||
final credential = await SignInWithApple.getAppleIDCredential(
|
||||
scopes: [ AppleIDAuthorizationScopes.email, AppleIDAuthorizationScopes.fullName ],
|
||||
);
|
||||
|
||||
idToken = credential.identityToken; // 👈 [핵심] 이 토큰을 백엔드로 전송
|
||||
email = credential.email;
|
||||
userName = "${credential.givenName ?? ''} ${credential.familyName ?? ''}".trim();
|
||||
}
|
||||
|
||||
if (idToken == null) {
|
||||
throw Exception("$provider 로그인에 실패했습니다.");
|
||||
}
|
||||
|
||||
// [TODO] 백엔드에 'mergeAccount(guestUserId, idToken, provider)' API 호출
|
||||
// 백엔드는 이 idToken을 검증하고, guestUserId의 데이터를
|
||||
// 소셜 계정의 마스터 ID로 병합(merge)해야 합니다.
|
||||
|
||||
// --- 백엔드 응답 (임시 시뮬레이션) ---
|
||||
// final backendResponse = await _puzzleService.mergeAccount(guestUserId, idToken, provider);
|
||||
// _session = await _identityService.saveSocialLogin(
|
||||
// newUserId: backendResponse.userId,
|
||||
// newUserName: backendResponse.userName,
|
||||
// newEmail: backendResponse.email,
|
||||
// provider: provider
|
||||
// );
|
||||
|
||||
// [임시] 백엔드 없으므로, 클라이언트 정보로 강제 저장 (테스트용)
|
||||
_session = await _identityService.saveSocialLogin(
|
||||
newUserId: "master-id-${email ?? provider}", // (임시)
|
||||
newUserName: userName ?? "Social User",
|
||||
newEmail: email ?? "No Email",
|
||||
provider: provider
|
||||
);
|
||||
// --- 임시 시뮬레이션 끝 ---
|
||||
|
||||
notifyListeners();
|
||||
|
||||
} catch (e) {
|
||||
debugPrint("$provider 로그인 오류: $e");
|
||||
// [TODO] 유저에게 "로그인에 실패했습니다." 스낵바 표시
|
||||
}
|
||||
}
|
||||
|
||||
/// 로그아웃
|
||||
Future<void> logout() async {
|
||||
await _googleSignIn.signOut();
|
||||
|
||||
_session = await _identityService.logout();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
// 1. 앱에서 사용할 색상표 정의
|
||||
final Map<String, MaterialColor> appColors = {
|
||||
'Blue': Colors.blue,
|
||||
'Green': Colors.green,
|
||||
'Red': Colors.red,
|
||||
'Purple': Colors.purple,
|
||||
'Orange': Colors.orange,
|
||||
'Teal': Colors.teal,
|
||||
};
|
||||
|
||||
class ThemeNotifier with ChangeNotifier {
|
||||
final String _themeKey = 'selected_theme';
|
||||
final String _darkModeKey = 'is_dark_mode'; // 다크 모드 저장 키
|
||||
|
||||
MaterialColor _currentColor = Colors.blue; // 기본값
|
||||
bool _isDarkMode = false; // 다크 모드 상태 변수
|
||||
|
||||
// --- Getters ---
|
||||
|
||||
// 라이트 모드용 테마
|
||||
ThemeData get currentTheme => ThemeData(
|
||||
// 🔽 [수정] M3의 권장 방식인 ColorScheme.fromSeed 사용
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: _currentColor, // 👈 _currentColor를 '씨앗색'으로 사용
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
// 🔺 'primarySwatch' 속성 제거
|
||||
);
|
||||
|
||||
// 다크 모드용 테마
|
||||
ThemeData get currentDarkTheme => ThemeData(
|
||||
// 🔽 [수정] 다크 모드에도 동일하게 적용
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: _currentColor, // 👈 _currentColor를 '씨앗색'으로 사용
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
// 🔺 'primarySwatch' 속성 제거
|
||||
);
|
||||
|
||||
// MaterialApp에 전달할 현재 테마 모드
|
||||
ThemeMode get currentThemeMode => _isDarkMode ? ThemeMode.dark : ThemeMode.light;
|
||||
|
||||
// SettingsScreen에서 사용할 현재 상태
|
||||
bool get isDarkMode => _isDarkMode;
|
||||
MaterialColor get currentColor => _currentColor;
|
||||
|
||||
// --- Methods ---
|
||||
|
||||
ThemeNotifier() {
|
||||
_loadTheme(); // 앱 시작 시 저장된 설정 불러오기
|
||||
}
|
||||
|
||||
// 저장된 테마와 '다크 모드' 설정을 함께 불러오기
|
||||
void _loadTheme() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// 색상 로드
|
||||
final themeName = prefs.getString(_themeKey) ?? 'Blue';
|
||||
_currentColor = appColors[themeName] ?? Colors.blue;
|
||||
|
||||
// 다크 모드 로드
|
||||
_isDarkMode = prefs.getBool(_darkModeKey) ?? false;
|
||||
|
||||
notifyListeners(); // 설정 로드 후 UI 갱신
|
||||
}
|
||||
|
||||
// 새 테마 색상 설정
|
||||
void setTheme(String themeName) async {
|
||||
final newColor = appColors[themeName];
|
||||
if (newColor == null) return;
|
||||
|
||||
_currentColor = newColor;
|
||||
notifyListeners(); // 테마 변경을 앱 전체에 알림
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
prefs.setString(_themeKey, themeName); // 선택한 테마 이름 저장
|
||||
}
|
||||
|
||||
// 다크 모드 토글
|
||||
void toggleTheme(bool isDark) async {
|
||||
_isDarkMode = isDark;
|
||||
notifyListeners(); // 모드 변경을 앱 전체에 알림
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
prefs.setBool(_darkModeKey, isDark); // 다크 모드 상태 저장
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
name: service_api
|
||||
description: All shared services, models, and API logic for the game center.
|
||||
version: 1.0.0
|
||||
publish_to: 'none' # 모노레포 내부용 패키지이므로 게시 안 함
|
||||
resolution: workspace
|
||||
environment:
|
||||
sdk: '^3.9.2'
|
||||
flutter: '>=3.10.0' # 👈 ThemeNotifier가 Flutter SDK를 필요로 함
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# 1. API 통신용
|
||||
http: ^1.0.0 # (PuzzleService가 사용)
|
||||
|
||||
# 2. 로컬 저장소용
|
||||
shared_preferences: ^2.0.0 # (IdentityService가 사용)
|
||||
uuid: ^4.0.0 # (IdentityService가 사용)
|
||||
flutter_secure_storage: ^9.0.0 # (버전은 최신 버전을 확인하세요)
|
||||
google_sign_in: ^7.2.0
|
||||
sign_in_with_apple: ^7.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
lints: ^3.0.0
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:service_api/service_api.dart';
|
||||
|
||||
void main() {
|
||||
test('adds one to input values', () {
|
||||
final calculator = Calculator();
|
||||
expect(calculator.addOne(2), 3);
|
||||
expect(calculator.addOne(-7), -6);
|
||||
expect(calculator.addOne(0), 1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user