Files
2025-11-10 14:14:08 +09:00

464 lines
18 KiB
Dart

import 'package:flutter/material.dart';
import 'dart:async';
import 'package:provider/provider.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'services/auth_service.dart';
import 'services/club_service.dart';
import 'services/member_service.dart';
import 'services/event_service.dart';
import 'services/score_service.dart';
import 'services/notification_service.dart';
import 'screens/auth/login_screen.dart';
import 'screens/auth/profile_screen.dart';
import 'screens/club/dashboard_screen.dart';
import 'screens/club/members_screen.dart';
import 'screens/club/events_screen.dart';
import 'screens/club/club_settings_screen.dart';
// import 'screens/score/club_statistics_screen.dart';
import 'widgets/dialog_actions.dart';
import 'screens/public_event/public_event_entry_screen.dart';
// 전역 네비게이터 키 (인증 오류 처리용)
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 알림 서비스 초기화
final notificationService = NotificationService();
await notificationService.initialize();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthService()),
ChangeNotifierProvider(create: (_) => ClubService()),
ChangeNotifierProvider(create: (_) => MemberService()),
ChangeNotifierProvider(create: (_) => EventService()),
ChangeNotifierProvider(create: (_) => ScoreService()),
],
child: Consumer<AuthService>(
builder: (context, authService, _) {
// 앱 시작 시 인증 서비스 초기화 (타이머 생성 회피: microtask 사용)
Future.microtask(() {
if (!authService.isInitialized) {
authService.initialize();
}
});
final initial = WidgetsBinding.instance.platformDispatcher.defaultRouteName;
return MaterialApp(
navigatorKey: navigatorKey, // 인증 오류 처리를 위한 전역 네비게이터 키
title: '볼링 매니저',
// 테마 자동 전환 비활성화(항상 라이트 테마)
themeMode: ThemeMode.light,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
visualDensity: VisualDensity.adaptivePlatformDensity,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
chipTheme: ChipThemeData(
shape: StadiumBorder(side: BorderSide.none),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 0),
),
dialogTheme: const DialogThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))),
titleTextStyle: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87),
contentTextStyle: TextStyle(fontSize: 14, color: Colors.black87),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: const StadiumBorder(),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: const StadiumBorder(),
),
),
cardTheme: const CardThemeData(
elevation: 1.5,
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8))),
actionTextColor: Colors.white,
),
),
darkTheme: ThemeData(
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue, brightness: Brightness.dark),
useMaterial3: true,
visualDensity: VisualDensity.adaptivePlatformDensity,
textTheme: const TextTheme(
bodyMedium: TextStyle(color: Colors.white70),
bodySmall: TextStyle(color: Colors.white60),
labelMedium: TextStyle(color: Colors.white70),
),
iconTheme: IconThemeData(
color: Colors.grey.shade200, // 다크에서 아이콘 대비 상향
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: const StadiumBorder(),
side: BorderSide(color: Colors.grey.shade400), // 다크에서 border 대비 강화
),
),
cardTheme: const CardThemeData(
elevation: 0.5, // 다크에서 쉐도우 약화
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8))),
),
),
// 로케일 설정 추가
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('ko', 'KR'),
Locale('en', 'US'),
],
locale: const Locale('ko', 'KR'),
// NOTE: 웹 딥링크 초기 빌드 시 child가 null일 수 있어 단순 패스 처리
builder: (context, child) => child ?? const SizedBox.shrink(),
initialRoute: initial,
onGenerateRoute: (settings) {
final name = settings.name ?? '';
// 공용 이벤트 딥링크 우선 처리
if (name.startsWith('/p/')) {
final hash = name.substring(3);
return MaterialPageRoute(
builder: (_) => PublicEventEntryScreen(publicHash: hash),
settings: settings,
);
}
if (name.startsWith('/events/')) {
final hash = name.substring(8);
return MaterialPageRoute(
builder: (_) => PublicEventEntryScreen(publicHash: hash),
settings: settings,
);
}
if (name == '/login') {
return MaterialPageRoute(builder: (_) => const LoginScreen(), settings: settings);
}
if (name == '/profile') {
return MaterialPageRoute(builder: (_) => const ProfileScreen(), settings: settings);
}
if (name == ClubSettingsScreen.routeName) {
return MaterialPageRoute(builder: (_) => const ClubSettingsScreen(), settings: settings);
}
// 루트('/') 진입 시에만 홈/로그인 분기
if (name == '/' || name.isEmpty) {
return MaterialPageRoute(
builder: (_) => authService.isAuthenticated ? const HomeScreen() : const LoginScreen(),
settings: settings,
);
}
// 알 수 없는 경로는 루트로 포워딩
return MaterialPageRoute(
builder: (_) => authService.isAuthenticated ? const HomeScreen() : const LoginScreen(),
settings: settings,
);
},
);
},
),
);
}
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _selectedIndex = 0;
bool _isInit = false;
List<Widget> get _widgetOptions => const <Widget>[
DashboardScreen(),
MembersScreen(),
EventsScreen(),
ProfileScreen(),
];
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
// 클럽 선택 다이얼로그 표시 (context를 인자로 받지 않음)
Future<void> _showClubSelectionDialog() async {
// 현재 시점의 컨텍스트를 통해 의존 객체를 가져오되, BuildContext 자체는 보관하지 않는다.
final currentContext = navigatorKey.currentContext!;
final clubService = Provider.of<ClubService>(currentContext, listen: false);
final authService = Provider.of<AuthService>(currentContext, listen: false);
final messenger = ScaffoldMessenger.of(currentContext);
// onTap 내부에서 사용할 서비스들을 await 이전에 캡처
final memberService = Provider.of<MemberService>(currentContext, listen: false);
final eventService = Provider.of<EventService>(currentContext, listen: false);
final scoreService = Provider.of<ScoreService>(currentContext, listen: false);
// 사용자의 모든 클럽 가져오기
if (clubService.clubs.isEmpty) {
try {
await clubService.fetchUserClubs();
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('클럽 목록을 불러오는데 실패했습니다: $e')),
);
return;
}
}
if (clubService.clubs.isEmpty) {
messenger.showSnackBar(
const SnackBar(content: Text('소속된 클럽이 없습니다')),
);
return;
}
// 다이얼로그 표시
showDialog(
context: navigatorKey.currentContext!,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('클럽 선택'),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: clubService.clubs.length,
itemBuilder: (context, index) {
final club = clubService.clubs[index];
final isSelected = clubService.currentClub?.id == club.id;
return ListTile(
title: Text(club.name),
subtitle: Text(club.description ?? ''),
trailing: isSelected ? const Icon(Icons.check, color: Colors.blue) : null,
onTap: () async {
Navigator.of(dialogContext).pop();
if (!isSelected) {
try {
// 백엔드 세션에 clubId 저장하고 클럽 정보 가져오기
await clubService.selectClub(club.id);
// 관련 서비스 초기화
memberService.initialize(authService.token!);
eventService.initialize(authService.token!);
scoreService.initialize(authService.token!);
// 데이터 다시 로드
await Future.wait([
memberService.fetchClubMembers(),
eventService.fetchClubEvents(),
]);
messenger.showSnackBar(
SnackBar(content: Text('${club.name} 클럽으로 변경되었습니다')),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('클럽 변경 중 오류가 발생했습니다: $e')),
);
}
}
},
);
},
),
),
actions: DialogActions.confirm(
onCancel: () => Navigator.of(dialogContext).pop(),
onConfirm: () => Navigator.of(dialogContext).pop(),
confirmText: '닫기',
),
);
},
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isInit) {
_initializeServices();
_isInit = true;
}
}
Future<void> _initializeServices() async {
// messenger는 try/catch 전역에서 재사용 (await 이후 context 접근 회피)
final messenger = ScaffoldMessenger.of(context);
try {
final authService = Provider.of<AuthService>(context, listen: false);
final clubService = Provider.of<ClubService>(context, listen: false);
final scoreService = Provider.of<ScoreService>(context, listen: false);
if (authService.isAuthenticated && authService.token != null) {
// 클럽 서비스 초기화
await clubService.initialize(authService.token!);
// 클럽이 선택된 경우 점수 서비스 초기화
if (clubService.currentClub != null) {
scoreService.initialize(authService.token!);
} else {
// 현재 선택된 클럽이 없는 경우
// 사용자의 클럽 목록 가져오기
await clubService.fetchUserClubs();
// 클럽이 하나만 있으면 자동으로 선택
if (clubService.clubs.length == 1) {
try {
await clubService.selectClub(clubService.clubs[0].id);
scoreService.initialize(authService.token!);
messenger.showSnackBar(
SnackBar(content: Text('${clubService.clubs[0].name} 클럽이 자동으로 선택되었습니다')),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('클럽 자동 선택 중 오류가 발생했습니다: $e')),
);
}
} else if (clubService.clubs.isNotEmpty) {
// 클럽이 여러 개 있으면 선택 다이얼로그 표시 (타이머 생성 회피: microtask 사용)
if (!mounted) return;
Future.microtask(() {
_showClubSelectionDialog();
});
} else {
// 클럽이 없는 경우 메시지 표시
messenger.showSnackBar(
const SnackBar(content: Text('소속된 클럽이 없습니다. 클럽을 생성하거나 초대를 요청하세요.')),
);
}
}
}
} catch (e) {
// 오류 처리
messenger.showSnackBar(
SnackBar(content: Text('서비스 초기화 중 오류가 발생했습니다: $e')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Consumer<ClubService>(
builder: (context, clubService, child) {
return InkWell(
onTap: () => _showClubSelectionDialog(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
clubService.currentClub?.name ?? '볼링 매니저',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_drop_down, size: 20),
],
),
);
},
),
actions: [
if (_selectedIndex == 0)
IconButton(
icon: const Icon(Icons.notifications),
onPressed: () async {
// 알림 권한 요청 (대시보드에서만 노출)
final notificationService = NotificationService();
final messenger = ScaffoldMessenger.of(context);
final hasPermission = await notificationService.requestPermission();
if (hasPermission) {
if (context.mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('알림 권한이 허용되었습니다')),
);
}
} else {
if (context.mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('알림 권한이 거부되었습니다. 설정에서 권한을 허용해주세요')),
);
}
}
},
),
],
),
body: _widgetOptions.elementAt(_selectedIndex),
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.dashboard),
label: '대시보드',
),
BottomNavigationBarItem(
icon: Icon(Icons.people),
label: '회원',
),
BottomNavigationBarItem(
icon: Icon(Icons.event),
label: '이벤트',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: '프로필',
),
],
currentIndex: _selectedIndex,
selectedItemColor: Colors.blue,
onTap: _onItemTapped,
),
);
}
}