Line data Source code
1 : import 'package:flutter/material.dart';
2 : import 'package:provider/provider.dart';
3 : import 'package:intl/intl.dart';
4 : import 'package:fl_chart/fl_chart.dart';
5 :
6 : import '../../services/auth_service.dart';
7 : import '../../services/club_service.dart';
8 : import '../../services/member_service.dart';
9 : import '../../services/score_service.dart';
10 : import '../../models/member_model.dart';
11 : import '../../models/score_model.dart';
12 : import 'member_statistics_screen.dart';
13 : import 'score_entry_screen.dart';
14 :
15 : class ClubStatisticsScreen extends StatefulWidget {
16 24 : const ClubStatisticsScreen({super.key});
17 :
18 0 : @override
19 0 : State<ClubStatisticsScreen> createState() => _ClubStatisticsScreenState();
20 : }
21 :
22 : class _ClubStatisticsScreenState extends State<ClubStatisticsScreen> {
23 : bool _isLoading = false;
24 : bool _isInit = false;
25 :
26 : Map<String, ScoreStatistics> _clubStatistics = {};
27 : List<Member> _members = [];
28 : Map<String, List<Score>> _recentScores = {};
29 :
30 0 : @override
31 : void didChangeDependencies() {
32 0 : super.didChangeDependencies();
33 0 : if (!_isInit) {
34 0 : _loadClubStatistics();
35 0 : _isInit = true;
36 : }
37 : }
38 :
39 0 : Future<void> _loadClubStatistics() async {
40 0 : setState(() {
41 0 : _isLoading = true;
42 : });
43 :
44 : try {
45 0 : final authService = Provider.of<AuthService>(context, listen: false);
46 0 : final clubService = Provider.of<ClubService>(context, listen: false);
47 0 : final memberService = Provider.of<MemberService>(context, listen: false);
48 0 : final scoreService = Provider.of<ScoreService>(context, listen: false);
49 :
50 0 : if (clubService.currentClub != null) {
51 : // 점수 서비스 초기화
52 0 : scoreService.initialize(authService.token!);
53 :
54 : // 회원 목록 로드
55 0 : await memberService.fetchClubMembers();
56 0 : _members = memberService.members;
57 :
58 : // 클럽 통계 로드
59 0 : _clubStatistics = await scoreService.fetchClubStatistics();
60 :
61 : // 각 회원별 최근 점수 로드 (최대 3개)
62 0 : _recentScores = {};
63 0 : for (final member in _members) {
64 : try {
65 0 : final scores = await scoreService.fetchMemberScores(member.id);
66 : // 날짜 기준 정렬 (최신순)
67 0 : scores.sort((a, b) => b.date.compareTo(a.date));
68 0 : _recentScores[member.id] = scores.take(3).toList();
69 : } catch (e) {
70 : // 개별 회원 점수 로드 실패 시 빈 배열로 처리
71 0 : _recentScores[member.id] = [];
72 : }
73 : }
74 : }
75 : } catch (e) {
76 0 : ScaffoldMessenger.of(
77 0 : context,
78 0 : ).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')));
79 : } finally {
80 0 : setState(() {
81 0 : _isLoading = false;
82 : });
83 : }
84 : }
85 :
86 0 : @override
87 : Widget build(BuildContext context) {
88 0 : return Scaffold(
89 0 : appBar: AppBar(title: const Text('클럽 통계')),
90 0 : body: _isLoading
91 : ? const Center(child: CircularProgressIndicator())
92 0 : : RefreshIndicator(
93 0 : onRefresh: _loadClubStatistics,
94 0 : child: SingleChildScrollView(
95 : physics: const AlwaysScrollableScrollPhysics(),
96 : padding: const EdgeInsets.all(16.0),
97 0 : child: Column(
98 : crossAxisAlignment: CrossAxisAlignment.start,
99 0 : children: [
100 : // 클럽 전체 통계
101 0 : _buildClubOverallStats(),
102 : const SizedBox(height: 24),
103 :
104 : // 회원별 통계
105 : const Text(
106 : '회원별 통계',
107 : style: TextStyle(
108 : fontSize: 18,
109 : fontWeight: FontWeight.bold,
110 : ),
111 : ),
112 : const SizedBox(height: 16),
113 :
114 : // 회원 목록
115 0 : ..._members.map((member) => _buildMemberStatsCard(member)),
116 : ],
117 : ),
118 : ),
119 : ),
120 0 : floatingActionButton: FloatingActionButton(
121 0 : onPressed: () async {
122 0 : await Navigator.of(context).push(
123 0 : MaterialPageRoute(builder: (context) => const ScoreEntryScreen()),
124 : );
125 : // 화면으로 돌아왔을 때 데이터 새로고침
126 0 : _loadClubStatistics();
127 : },
128 : backgroundColor: Colors.blue,
129 : child: const Icon(Icons.add, color: Colors.white),
130 : ),
131 : );
132 : }
133 :
134 0 : Widget _buildClubOverallStats() {
135 : // 클럽 전체 통계가 없는 경우
136 0 : if (!_clubStatistics.containsKey('overall')) {
137 : return const Card(
138 : child: Padding(
139 : padding: EdgeInsets.all(16.0),
140 : child: Center(child: Text('클럽 전체 통계 데이터가 없습니다')),
141 : ),
142 : );
143 : }
144 :
145 0 : final overallStats = _clubStatistics['overall']!;
146 :
147 0 : return Card(
148 : elevation: 2,
149 0 : shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
150 0 : child: Padding(
151 : padding: const EdgeInsets.all(16.0),
152 0 : child: Column(
153 : crossAxisAlignment: CrossAxisAlignment.start,
154 0 : children: [
155 : const Text(
156 : '클럽 전체 통계',
157 : style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
158 : ),
159 : const SizedBox(height: 16),
160 0 : Row(
161 0 : children: [
162 0 : Expanded(
163 0 : child: _buildStatItem(
164 : label: '클럽 평균',
165 0 : value: overallStats.average.toStringAsFixed(1),
166 : icon: Icons.score,
167 : color: Colors.blue,
168 : ),
169 : ),
170 0 : Expanded(
171 0 : child: _buildStatItem(
172 : label: '최고 점수',
173 0 : value: overallStats.highScore.toString(),
174 : icon: Icons.emoji_events,
175 : color: Colors.amber,
176 : ),
177 : ),
178 : ],
179 : ),
180 : const SizedBox(height: 16),
181 0 : Row(
182 0 : children: [
183 0 : Expanded(
184 0 : child: _buildStatItem(
185 : label: '최저 점수',
186 0 : value: overallStats.lowScore.toString(),
187 : icon: Icons.arrow_downward,
188 : color: Colors.red,
189 : ),
190 : ),
191 0 : Expanded(
192 0 : child: _buildStatItem(
193 : label: '총 게임 수',
194 0 : value: overallStats.gamesPlayed.toString(),
195 : icon: Icons.sports,
196 : color: Colors.green,
197 : ),
198 : ),
199 : ],
200 : ),
201 :
202 : // 클럽 평균 점수 추이 그래프
203 : const SizedBox(height: 24),
204 : const Text(
205 : '클럽 평균 점수 추이',
206 : style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
207 : ),
208 : const SizedBox(height: 8),
209 0 : SizedBox(height: 180, child: _buildClubAverageChart()),
210 :
211 : // 추가 통계 정보
212 0 : if (overallStats.additionalStats != null) ...[
213 : const SizedBox(height: 16),
214 : const Divider(),
215 : const SizedBox(height: 8),
216 0 : ...overallStats.additionalStats!.entries.map((entry) {
217 0 : return Padding(
218 : padding: const EdgeInsets.only(bottom: 8.0),
219 0 : child: Row(
220 : mainAxisAlignment: MainAxisAlignment.spaceBetween,
221 0 : children: [
222 0 : Text(entry.key, style: const TextStyle(fontSize: 14)),
223 0 : Text(
224 0 : entry.value.toString(),
225 : style: const TextStyle(
226 : fontSize: 14,
227 : fontWeight: FontWeight.bold,
228 : ),
229 : ),
230 : ],
231 : ),
232 : );
233 : }),
234 : ],
235 : ],
236 : ),
237 : ),
238 : );
239 : }
240 :
241 0 : Widget _buildStatItem({
242 : required String label,
243 : required String value,
244 : required IconData icon,
245 : required Color color,
246 : }) {
247 0 : return Column(
248 0 : children: [
249 0 : Icon(icon, size: 28, color: color),
250 : const SizedBox(height: 8),
251 0 : Text(
252 : value,
253 0 : style: TextStyle(
254 : fontSize: 18,
255 : fontWeight: FontWeight.bold,
256 : color: color,
257 : ),
258 : ),
259 0 : Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
260 : ],
261 : );
262 : }
263 :
264 0 : Widget _buildMemberStatsCard(Member member) {
265 : // 회원 통계 정보
266 0 : final memberStats = _clubStatistics[member.id];
267 : // 회원 최근 점수
268 0 : final recentScores = _recentScores[member.id] ?? [];
269 :
270 0 : return Card(
271 : margin: const EdgeInsets.only(bottom: 12),
272 0 : shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
273 0 : child: InkWell(
274 0 : onTap: () {
275 0 : Navigator.of(context).push(
276 0 : MaterialPageRoute(
277 0 : builder: (context) => MemberStatisticsScreen(memberId: member.id),
278 : ),
279 : );
280 : },
281 0 : borderRadius: BorderRadius.circular(12),
282 0 : child: Padding(
283 : padding: const EdgeInsets.all(16.0),
284 0 : child: Column(
285 : crossAxisAlignment: CrossAxisAlignment.start,
286 0 : children: [
287 : // 회원 정보
288 0 : Row(
289 0 : children: [
290 0 : CircleAvatar(
291 : radius: 24,
292 0 : backgroundColor: Colors.blue.shade100,
293 0 : backgroundImage: member.profileImage != null
294 0 : ? NetworkImage(member.profileImage!)
295 : : null,
296 0 : child: member.profileImage == null
297 0 : ? Text(
298 0 : member.name.isNotEmpty
299 0 : ? member.name[0].toUpperCase()
300 : : '?',
301 : style: const TextStyle(
302 : fontSize: 20,
303 : color: Colors.blue,
304 : fontWeight: FontWeight.bold,
305 : ),
306 : )
307 : : null,
308 : ),
309 : const SizedBox(width: 12),
310 0 : Expanded(
311 0 : child: Column(
312 : crossAxisAlignment: CrossAxisAlignment.start,
313 0 : children: [
314 0 : Text(
315 0 : member.name,
316 : style: const TextStyle(
317 : fontSize: 16,
318 : fontWeight: FontWeight.bold,
319 : ),
320 : ),
321 : if (memberStats != null)
322 0 : Text(
323 0 : '평균: ${memberStats.average.toStringAsFixed(1)} | 최고: ${memberStats.highScore} | 게임: ${memberStats.gamesPlayed}',
324 0 : style: TextStyle(
325 : fontSize: 12,
326 0 : color: Colors.grey[600],
327 : ),
328 : ),
329 : ],
330 : ),
331 : ),
332 : const Icon(Icons.chevron_right),
333 : ],
334 : ),
335 :
336 : // 최근 점수 표시
337 0 : if (recentScores.isNotEmpty) ...[
338 : const SizedBox(height: 12),
339 : const Divider(),
340 : const SizedBox(height: 8),
341 : const Text(
342 : '최근 점수',
343 : style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
344 : ),
345 : const SizedBox(height: 8),
346 0 : Row(
347 : mainAxisAlignment: MainAxisAlignment.spaceAround,
348 0 : children: recentScores.map((score) {
349 0 : return Column(
350 0 : children: [
351 0 : Text(
352 0 : score.totalScore.toString(),
353 : style: const TextStyle(
354 : fontSize: 16,
355 : fontWeight: FontWeight.bold,
356 : color: Colors.blue,
357 : ),
358 : ),
359 0 : Text(
360 0 : DateFormat('MM/dd').format(score.date),
361 0 : style: TextStyle(
362 : fontSize: 10,
363 0 : color: Colors.grey[600],
364 : ),
365 : ),
366 : ],
367 : );
368 0 : }).toList(),
369 : ),
370 : ],
371 : ],
372 : ),
373 : ),
374 : ),
375 : );
376 : }
377 :
378 0 : Widget _buildClubAverageChart() {
379 : // 클럽 통계가 없는 경우
380 0 : if (!_clubStatistics.containsKey('overall')) {
381 : return const Center(child: Text('통계 데이터가 없습니다'));
382 : }
383 :
384 : // 회원별 평균 점수 데이터 추출
385 0 : final memberAverages = <String, double>{};
386 :
387 : // 회원별 통계 정보에서 평균 점수 추출
388 0 : _clubStatistics.forEach((key, stats) {
389 0 : if (key != 'overall') {
390 : // 회원 ID에 해당하는 회원 찾기
391 0 : final member = _members.firstWhere(
392 0 : (m) => m.id == key,
393 0 : orElse: () => Member(
394 : id: key,
395 : userId: key,
396 : name: 'Unknown',
397 : email: '',
398 : clubId: '',
399 : isActive: true,
400 : ),
401 : );
402 0 : memberAverages[member.name] = stats.average;
403 : }
404 : });
405 :
406 : // 평균 점수 기준으로 정렬 (내림차순)
407 0 : final sortedEntries = memberAverages.entries.toList()
408 0 : ..sort((a, b) => b.value.compareTo(a.value));
409 :
410 : // 최대 8명만 표시
411 0 : final displayEntries = sortedEntries.length > 8
412 0 : ? sortedEntries.sublist(0, 8)
413 : : sortedEntries;
414 :
415 : // 그래프 데이터 생성
416 0 : final barGroups = <BarChartGroupData>[];
417 0 : for (int i = 0; i < displayEntries.length; i++) {
418 0 : barGroups.add(
419 0 : BarChartGroupData(
420 : x: i,
421 0 : barRods: [
422 0 : BarChartRodData(
423 0 : toY: displayEntries[i].value,
424 : color: Colors.blue,
425 : width: 16,
426 : borderRadius: const BorderRadius.only(
427 : topLeft: Radius.circular(4),
428 : topRight: Radius.circular(4),
429 : ),
430 : ),
431 : ],
432 : ),
433 : );
434 : }
435 :
436 : // 최소/최대 점수 계산 (Y축 범위 설정용)
437 0 : final minScore = displayEntries.isEmpty
438 : ? 0.0
439 0 : : displayEntries.map((e) => e.value).reduce((a, b) => a < b ? a : b) -
440 : 10;
441 0 : final maxScore = displayEntries.isEmpty
442 : ? 300.0
443 0 : : displayEntries.map((e) => e.value).reduce((a, b) => a > b ? a : b) +
444 : 10;
445 :
446 0 : return BarChart(
447 0 : BarChartData(
448 : alignment: BarChartAlignment.spaceAround,
449 : maxY: maxScore,
450 0 : minY: minScore < 0 ? 0 : minScore,
451 : gridData: const FlGridData(show: true, horizontalInterval: 50),
452 0 : borderData: FlBorderData(
453 : show: true,
454 0 : border: Border.all(color: Colors.grey.shade300),
455 : ),
456 0 : titlesData: FlTitlesData(
457 : show: true,
458 : rightTitles: const AxisTitles(
459 : sideTitles: SideTitles(showTitles: false),
460 : ),
461 : topTitles: const AxisTitles(
462 : sideTitles: SideTitles(showTitles: false),
463 : ),
464 0 : bottomTitles: AxisTitles(
465 0 : sideTitles: SideTitles(
466 : showTitles: true,
467 0 : getTitlesWidget: (value, meta) {
468 0 : final index = value.toInt();
469 0 : if (index >= 0 && index < displayEntries.length) {
470 : // 회원 이름 표시 (최대 5글자)
471 0 : final name = displayEntries[index].key;
472 0 : final shortName = name.length > 5
473 0 : ? '${name.substring(0, 4)}...'
474 0 : : name;
475 :
476 0 : return Text(shortName, style: const TextStyle(fontSize: 10));
477 : }
478 : return const SizedBox();
479 : },
480 : reservedSize: 30,
481 : ),
482 : ),
483 0 : leftTitles: AxisTitles(
484 0 : sideTitles: SideTitles(
485 : showTitles: true,
486 : interval: 50,
487 0 : getTitlesWidget: (value, meta) {
488 0 : return Text(
489 0 : value.toInt().toString(),
490 : style: const TextStyle(fontSize: 10),
491 : );
492 : },
493 : reservedSize: 40,
494 : ),
495 : ),
496 : ),
497 : barGroups: barGroups,
498 : ),
499 : );
500 : }
501 : }
|