...
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user