This commit is contained in:
2025-11-25 16:34:13 +09:00
parent 92a4525091
commit bc57468aaa
29 changed files with 2206 additions and 641 deletions
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:playwith_core/playwith_core.dart'; // SettingsNotifier
import 'intro_view.dart';
class IntroScreen extends StatelessWidget {
final WidgetBuilder nextScreenBuilder;
const IntroScreen({
super.key,
required this.nextScreenBuilder,
});
void _navigateToNextScreen(BuildContext context) {
// 인트로 종료 후 다음 화면(Lobby)으로 이동 (뒤로가기 불가)
Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => nextScreenBuilder(context),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 800), // 부드러운 전환
),
);
}
@override
Widget build(BuildContext context) {
// SettingsNotifier 싱글톤에서 현재 색상 가져오기
final Color currentColor = SettingsNotifier().currentColor;
return Scaffold(
// 배경색은 테마 배경색 사용
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Center(
child: IntroViewFlutter(
mainColor: currentColor,
onAnimationFinished: () {
_navigateToNextScreen(context);
},
),
),
);
}
}
+194
View File
@@ -0,0 +1,194 @@
import 'dart:async';
import 'package:flutter/material.dart';
/// "SBSPACE"를 한 줄로 그리는 IntroView
class IntroViewFlutter extends StatefulWidget {
final Color mainColor;
final VoidCallback onAnimationFinished;
const IntroViewFlutter({
super.key,
required this.mainColor,
required this.onAnimationFinished,
});
@override
State<IntroViewFlutter> createState() => _IntroViewFlutterState();
}
class _IntroViewFlutterState extends State<IntroViewFlutter>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<int> _logoTextAnimation;
late final Animation<int> _missionTextAnimation;
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;
const int missionDuration = _missionString.length * 100;
final int totalAnimationDuration = logoDuration + missionDuration;
_controller = AnimationController(
duration: Duration(milliseconds: totalAnimationDuration),
vsync: this,
);
_logoTextAnimation = IntTween(begin: 0, end: _logoString.length).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.0, logoDuration / totalAnimationDuration, curve: Curves.linear),
),
);
_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,
);
},
);
}
}
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,
});
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,
);
}
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) {
final double logoFontSize = size.shortestSide / 6.0;
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();
final double targetWidth = size.width * 0.9;
final double scale = targetWidth / tpMissionTemp.width;
final double missionFontSize = tempMissionFontSize * scale;
final tpLogoFull = _createTextPainter(_buildLogoSpan(_logoString.length), logoFontSize);
final TextPainter tpMissionFull = TextPainter(
text: TextSpan(
text: _missionString,
style: TextStyle(
color: mainColor,
fontSize: missionFontSize,
fontFamily: fontFamily,
fontWeight: FontWeight.bold
)
),
textDirection: TextDirection.ltr,
)..layout();
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;
final double startxLogo = (size.width - tpLogoFull.width) / 2.0;
final double startxMission = (size.width - tpMissionFull.width) / 2.0;
final tpLogoSub = _createTextPainter(_buildLogoSpan(logoTextLength), logoFontSize);
tpLogoSub.paint(canvas, Offset(startxLogo, startyLogo));
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;
}
}
-94
View File
@@ -1,94 +0,0 @@
import 'dart:io'; // Platform 확인용
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:playwith_core/playwith_core.dart';
import 'lobby_screen.dart';
class IntroScreen extends StatefulWidget {
const IntroScreen({super.key});
@override
State<IntroScreen> createState() => _IntroScreenState();
}
class _IntroScreenState extends State<IntroScreen> {
final _nicknameController = TextEditingController();
Future<void> _enterLobby() async {
if (_nicknameController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("닉네임을 입력해주세요.")),
);
return;
}
// [수정됨] 플랫폼별 권한 분기 처리
if (Platform.isAndroid) {
// 🤖 안드로이드: 명시적 권한 요청 필요
Map<Permission, PermissionStatus> statuses = await [
Permission.location, // 안드로이드 12 이하
Permission.nearbyWifiDevices, // 안드로이드 13 이상
].request();
// 로그 확인용
bool isNearby = statuses[Permission.nearbyWifiDevices]?.isGranted ?? false;
bool isLocation = statuses[Permission.location]?.isGranted ?? false;
print("Android 권한 Check: Nearby=$isNearby, Location=$isLocation");
// 둘 다 거부되면 진행 불가 (단, 버전에 따라 하나만 있어도 됨)
// 여기서는 "둘 다 false일 때만" 막는 것으로 완화
if (!isNearby && !isLocation) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("❌ 안드로이드는 권한이 필요합니다.")),
);
return;
}
} else if (Platform.isIOS) {
// 🍎 iOS: 별도 요청 불필요
// Info.plist에 설정만 잘 되어 있다면,
// NetworkManager가 start() 될 때 시스템이 알아서 물어봅니다.
print("iOS는 권한 체크를 건너뜁니다. (실행 시 자동 팝업됨)");
}
// 초기화 및 입장
NetworkManager().initialize(nickname: _nicknameController.text.trim());
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const LobbyScreen()),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('PlayWith', style: TextStyle(fontSize: 40, fontWeight: FontWeight.bold)),
const SizedBox(height: 40),
TextField(
controller: _nicknameController,
decoration: const InputDecoration(
labelText: '닉네임을 입력하세요',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _enterLobby,
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 50)),
child: const Text('입장하기'),
),
],
),
),
),
);
}
}
+167
View File
@@ -0,0 +1,167 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:playwith_core/playwith_core.dart';
import 'lobby_screen.dart';
import 'screens/settings_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _nicknameController = TextEditingController();
final _settings = SettingsNotifier();
@override
void initState() {
super.initState();
// 저장된 닉네임 반영
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted && _settings.nickname.isNotEmpty) {
setState(() {
_nicknameController.text = _settings.nickname;
});
}
});
_settings.addListener(_syncSettings);
}
@override
void dispose() {
_settings.removeListener(_syncSettings);
_nicknameController.dispose();
super.dispose();
}
void _syncSettings() {
if (_nicknameController.text != _settings.nickname) {
if (mounted) {
setState(() {
_nicknameController.text = _settings.nickname;
});
}
}
if (mounted) setState(() {});
}
Future<void> _enterLobby() async {
final inputNick = _nicknameController.text.trim();
if (inputNick.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("닉네임을 입력해주세요.")));
return;
}
// 안드로이드 권한 체크
if (Platform.isAndroid) {
Map<Permission, PermissionStatus> statuses = await [
Permission.location,
Permission.nearbyWifiDevices,
].request();
bool isNearby = statuses[Permission.nearbyWifiDevices]?.isGranted ?? false;
bool isLocation = statuses[Permission.location]?.isGranted ?? false;
if (!isNearby && !isLocation) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("⚠️ 권한 허용이 필요합니다.")));
}
}
// 변경 사항 저장
if (inputNick != _settings.nickname) {
await _settings.setProfile(inputNick, _settings.avatarIndex);
}
// [수정] 초기화 시 닉네임과 이미지를 함께 전달
NetworkManager().initialize(
nickname: _settings.nickname,
profileImage: _settings.profileImageBase64, // [추가]
);
if (!mounted) return;
Navigator.push(context, MaterialPageRoute(builder: (_) => const LobbyScreen()));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.settings, color: Colors.grey),
tooltip: "설정",
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())),
)
],
),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('PlayWith', style: TextStyle(fontSize: 40, fontWeight: FontWeight.bold)),
const SizedBox(height: 40),
// [핵심] AvatarWidget 사용 (Core 컴포넌트)
ListenableBuilder(
listenable: _settings,
builder: (context, _) {
return GestureDetector(
onTap: () => _settings.pickProfileImage(),
child: Stack(
children: [
AvatarWidget(
base64Image: _settings.profileImageBase64,
colorValue: Colors.primaries[_settings.avatarIndex % Colors.primaries.length].value,
nickname: _nicknameController.text,
size: 120,
),
Positioned(
right: 0, bottom: 0,
child: Container(
padding: const EdgeInsets.all(8),
decoration: const BoxDecoration(color: Colors.blue, shape: BoxShape.circle),
child: const Icon(Icons.camera_alt, size: 20, color: Colors.white),
),
),
],
),
);
},
),
const SizedBox(height: 30),
TextField(
controller: _nicknameController,
textAlign: TextAlign.center,
decoration: const InputDecoration(
labelText: '닉네임',
border: OutlineInputBorder(),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
),
const SizedBox(height: 30),
ElevatedButton(
onPressed: _enterLobby,
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: const Text('입장하기', style: TextStyle(fontSize: 18)),
),
],
),
),
),
);
}
}
+44 -32
View File
@@ -1,21 +1,23 @@
import 'package:flutter/material.dart';
import 'package:playwith_core/playwith_core.dart';
import 'package:playwith_game_quiz/quiz_game.dart'; // 퀴즈 모듈 import
import 'intro_screen.dart';
import 'package:playwith_game_quiz/quiz_game.dart';
import 'login_screen.dart'; // [수정] 인트로 스크린 import (경로가 다르면 수정 필요)
import 'intro/intro_screen.dart'; // 만약 intro 폴더에 넣으셨다면 이 경로 사용
import 'lobby_screen.dart';
void main() {
// 1. 플러터 바인딩 초기화
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// 2. 사운드 리소스 주입 (여기가 핵심!)
// AssetSource는 'assets/' 접두어를 자동으로 붙이므로 그 하위 경로만 적습니다.
SoundManager().initialize(soundPaths: {
SoundKey.bgm: 'audio/bgm.mp3',
SoundKey.correct: 'audio/correct.mp3',
SoundKey.wrong: 'audio/wrong.mp3',
SoundKey.win: 'audio/win.mp3',
SoundKey.click: 'audio/correct.mp3',
});
await NotificationManager().initialize();
runApp(const PlayWithApp());
}
@@ -28,43 +30,53 @@ class PlayWithApp extends StatefulWidget {
class _PlayWithAppState extends State<PlayWithApp> {
final _net = NetworkManager();
final _settings = SettingsNotifier();
// 등록된 게임 목록
final List<BaseGame> _games = [
QuizGame(), // 여기서 등록!
QuizGame(),
];
@override
void initState() {
super.initState();
// [전역 라우팅] 네트워크 메시지를 감시하다가 'GAME_START'가 오면 해당 게임 실행
_net.messageStream.listen((data) {
if (data['type'] == 'GAME_START') {
final String gameId = data['gameId'];
// ID에 맞는 게임 찾기
final game = _games.firstWhere(
(g) => g.id == gameId,
orElse: () => throw Exception("Game not found: $gameId")
);
// 게임 화면으로 이동 (네비게이터 키를 안 쓰고 있어서 간단히 처리 불가, 아래 설명 참조)
// 실제로는 GlobalKey<NavigatorState>를 쓰거나, 현재 context를 찾아야 함.
// MVP에서는 LobbyScreen 내부에서 처리하는 것이 안전함.
}
// 라우팅 로직...
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'PlayWith',
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const IntroScreen(),
return ListenableBuilder(
listenable: _settings,
builder: (context, child) {
return MaterialApp(
title: 'PlayWith',
theme: _settings.currentTheme,
themeMode: _settings.currentThemeMode,
builder: (context, child) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(_settings.fontScale),
),
child: child!,
);
},
// [핵심 수정] 앱 시작 시 IntroScreen을 먼저 보여줌
// nextScreenBuilder를 통해 애니메이션 종료 후 갈 곳(Lobby) 지정
home: IntroScreen(
nextScreenBuilder: (context) => const LoginScreen(),
),
);
},
);
}
}
}
// [팁] 인트로와 닉네임 입력(IntroScreen.dart의 기존 로직)을 연결하기 위한 래퍼
// 기존에 있던 닉네임 입력 화면(IntroScreen)과 이름이 겹치므로,
// 기존의 닉네임 입력 화면은 'LoginScreen'이나 'NameInputScreen'으로 이름을 바꾸는 게 좋습니다.
// 만약 'IntroScreen' 파일이 닉네임 입력 화면이었다면,
// 이번에 만든 애니메이션 화면을 'SplashAnimationScreen' 등으로 이름을 지어서 구분해주세요.
// 여기서는 이번에 만든 애니메이션 화면을 'IntroAnimationScreen'이라고 가정하고,
// 애니메이션이 끝나면 -> 닉네임 입력 화면(기존 IntroScreen) -> 로비 순서로 가는 게 자연스럽습니다.
+187
View File
@@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:playwith_core/playwith_core.dart'; // AvatarWidget 포함됨
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final _nickController = TextEditingController();
final _settings = SettingsNotifier();
@override
void initState() {
super.initState();
_nickController.text = _settings.nickname;
}
@override
void dispose() {
_nickController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("설정")),
body: ListenableBuilder(
listenable: _settings,
builder: (context, _) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
// 1. 프로필 설정 섹션
_buildSectionTitle("프로필 설정"),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// 아바타 변경 영역
GestureDetector(
onTap: () => _settings.pickProfileImage(),
child: Stack(
alignment: Alignment.bottomRight,
children: [
AvatarWidget(
base64Image: _settings.profileImageBase64,
colorValue: Colors.primaries[_settings.avatarIndex % Colors.primaries.length].value,
nickname: _nickController.text,
size: 100,
),
Container(
padding: const EdgeInsets.all(6),
decoration: const BoxDecoration(color: Colors.blue, shape: BoxShape.circle),
child: const Icon(Icons.edit, size: 16, color: Colors.white),
),
],
),
),
if (_settings.profileImageBase64 != null)
TextButton(
onPressed: () => _settings.clearProfileImage(),
child: const Text("이미지 삭제 (기본값 사용)", style: TextStyle(color: Colors.red)),
),
const SizedBox(height: 20),
// 닉네임 입력
TextField(
controller: _nickController,
decoration: const InputDecoration(
labelText: "닉네임",
border: OutlineInputBorder(),
helperText: "게임에서 사용할 이름을 입력하세요.",
),
onChanged: (val) => _settings.setProfile(val, _settings.avatarIndex),
),
const SizedBox(height: 10),
// 기본 아바타 색상 선택 (이미지 없을 때 사용)
const Align(alignment: Alignment.centerLeft, child: Text("기본 배경색")),
const SizedBox(height: 5),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(Colors.primaries.length, (index) {
final isSelected = _settings.avatarIndex == index;
return GestureDetector(
onTap: () => _settings.setProfile(_nickController.text, index),
child: Container(
margin: const EdgeInsets.only(right: 8),
width: 30,
height: 30,
decoration: BoxDecoration(
color: Colors.primaries[index],
shape: BoxShape.circle,
border: isSelected ? Border.all(color: Colors.black, width: 2) : null,
),
child: isSelected ? const Icon(Icons.check, size: 16, color: Colors.white) : null,
),
);
}),
),
),
],
),
),
),
const SizedBox(height: 20),
// 2. 디스플레이 설정 섹션
_buildSectionTitle("화면 설정"),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
title: const Text("다크 모드"),
value: _settings.isDarkMode,
onChanged: (val) => _settings.toggleDarkMode(val),
),
const Divider(),
const Text("글자 크기", style: TextStyle(fontWeight: FontWeight.bold)),
Slider(
value: _settings.fontScale,
min: 0.8,
max: 1.5,
divisions: 7,
label: "${(_settings.fontScale * 100).toInt()}%",
onChanged: (val) => _settings.setFontScale(val),
),
Text(
"이 크기로 보입니다.",
style: TextStyle(fontSize: 16 * _settings.fontScale),
),
const Divider(),
const Text("테마 색상", style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
children: appColors.entries.map((entry) {
final isSelected = _settings.themeColorName == entry.key;
return GestureDetector(
onTap: () => _settings.setThemeColor(entry.key),
child: Container(
width: 40, height: 40,
decoration: BoxDecoration(
color: entry.value,
shape: BoxShape.circle,
border: isSelected ? Border.all(color: Colors.black, width: 3) : null,
boxShadow: [if(isSelected) const BoxShadow(blurRadius: 5, color: Colors.black26)],
),
child: isSelected ? const Icon(Icons.check, color: Colors.white) : null,
),
);
}).toList(),
),
],
),
),
),
],
);
},
),
);
}
Widget _buildSectionTitle(String title) {
return Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8),
child: Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
);
}
}