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 'score_entry_screen.dart';
13 :
14 : class MemberStatisticsScreen extends StatefulWidget {
15 : final String memberId;
16 :
17 0 : const MemberStatisticsScreen({super.key, required this.memberId});
18 :
19 0 : @override
20 0 : State<MemberStatisticsScreen> createState() => _MemberStatisticsScreenState();
21 : }
22 :
23 : class _MemberStatisticsScreenState extends State<MemberStatisticsScreen>
24 : with SingleTickerProviderStateMixin {
25 : bool _isLoading = false;
26 : bool _isInit = false;
27 : late TabController _tabController;
28 :
29 : Member? _member;
30 : List<Score> _scores = [];
31 : ScoreStatistics? _statistics;
32 :
33 0 : @override
34 : void initState() {
35 0 : super.initState();
36 0 : _tabController = TabController(length: 2, vsync: this);
37 : }
38 :
39 0 : @override
40 : void dispose() {
41 0 : _tabController.dispose();
42 0 : super.dispose();
43 : }
44 :
45 0 : @override
46 : void didChangeDependencies() {
47 0 : super.didChangeDependencies();
48 0 : if (!_isInit) {
49 0 : _loadMemberData();
50 0 : _isInit = true;
51 : }
52 : }
53 :
54 0 : Future<void> _loadMemberData() async {
55 0 : setState(() {
56 0 : _isLoading = true;
57 : });
58 :
59 : try {
60 0 : final authService = Provider.of<AuthService>(context, listen: false);
61 0 : final clubService = Provider.of<ClubService>(context, listen: false);
62 0 : final memberService = Provider.of<MemberService>(context, listen: false);
63 0 : final scoreService = Provider.of<ScoreService>(context, listen: false);
64 :
65 0 : if (clubService.currentClub != null) {
66 : // 회원 정보 로드
67 0 : _member = await memberService.fetchMemberById(widget.memberId);
68 :
69 : // 점수 서비스 초기화
70 0 : scoreService.initialize(authService.token!);
71 :
72 : // 회원 점수 및 통계 로드
73 0 : await Future.wait([
74 0 : scoreService.fetchMemberScores(widget.memberId).then((scores) {
75 0 : _scores = scores;
76 : }),
77 0 : scoreService.fetchMemberStatistics(widget.memberId).then((stats) {
78 0 : _statistics = stats;
79 : }),
80 : ]);
81 : }
82 : } catch (e) {
83 0 : ScaffoldMessenger.of(
84 0 : context,
85 0 : ).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')));
86 : } finally {
87 0 : setState(() {
88 0 : _isLoading = false;
89 : });
90 : }
91 : }
92 :
93 0 : @override
94 : Widget build(BuildContext context) {
95 0 : return Scaffold(
96 0 : appBar: AppBar(
97 0 : title: Text(_member?.name ?? '회원 통계'),
98 0 : bottom: TabBar(
99 0 : controller: _tabController,
100 : tabs: const [
101 : Tab(text: '통계'),
102 : Tab(text: '점수 기록'),
103 : ],
104 : ),
105 : ),
106 0 : body: _isLoading
107 : ? const Center(child: CircularProgressIndicator())
108 0 : : TabBarView(
109 0 : controller: _tabController,
110 0 : children: [_buildStatisticsTab(), _buildScoresTab()],
111 : ),
112 0 : floatingActionButton: FloatingActionButton(
113 0 : onPressed: () async {
114 0 : await Navigator.of(context).push(
115 0 : MaterialPageRoute(
116 0 : builder: (context) => ScoreEntryScreen(memberId: widget.memberId),
117 : ),
118 : );
119 : // 화면으로 돌아왔을 때 데이터 새로고침
120 0 : _loadMemberData();
121 : },
122 : backgroundColor: Colors.blue,
123 : child: const Icon(Icons.add, color: Colors.white),
124 : ),
125 : );
126 : }
127 :
128 0 : Widget _buildStatisticsTab() {
129 0 : if (_statistics == null) {
130 : return const Center(child: Text('통계 데이터가 없습니다'));
131 : }
132 :
133 0 : return RefreshIndicator(
134 0 : onRefresh: _loadMemberData,
135 0 : child: SingleChildScrollView(
136 : physics: const AlwaysScrollableScrollPhysics(),
137 : padding: const EdgeInsets.all(16.0),
138 0 : child: Column(
139 : crossAxisAlignment: CrossAxisAlignment.start,
140 0 : children: [
141 : // 회원 정보 카드
142 0 : if (_member != null) _buildMemberInfoCard(),
143 0 : const SizedBox(height: 24),
144 :
145 : // 주요 통계 카드
146 0 : _buildMainStatsCard(),
147 0 : const SizedBox(height: 24),
148 :
149 : // 추가 통계 정보
150 0 : if (_statistics!.additionalStats != null)
151 0 : _buildAdditionalStatsCard(),
152 :
153 : // 통계 그래프
154 0 : const SizedBox(height: 24),
155 0 : Card(
156 0 : child: Padding(
157 : padding: const EdgeInsets.all(16.0),
158 0 : child: Column(
159 : crossAxisAlignment: CrossAxisAlignment.start,
160 0 : children: [
161 : const Text(
162 : '점수 추이',
163 : style: TextStyle(
164 : fontSize: 16,
165 : fontWeight: FontWeight.bold,
166 : ),
167 : ),
168 : const SizedBox(height: 16),
169 0 : SizedBox(height: 200, child: _buildScoreChart()),
170 : ],
171 : ),
172 : ),
173 : ),
174 : ],
175 : ),
176 : ),
177 : );
178 : }
179 :
180 0 : Widget _buildMemberInfoCard() {
181 0 : return Card(
182 : elevation: 2,
183 0 : shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
184 0 : child: Padding(
185 : padding: const EdgeInsets.all(16.0),
186 0 : child: Row(
187 0 : children: [
188 0 : CircleAvatar(
189 : radius: 30,
190 0 : backgroundColor: Colors.blue.shade100,
191 0 : backgroundImage: _member!.profileImage != null
192 0 : ? NetworkImage(_member!.profileImage!)
193 : : null,
194 0 : child: _member!.profileImage == null
195 0 : ? Text(
196 0 : _member!.name.isNotEmpty
197 0 : ? _member!.name[0].toUpperCase()
198 : : '?',
199 : style: const TextStyle(
200 : fontSize: 24,
201 : color: Colors.blue,
202 : fontWeight: FontWeight.bold,
203 : ),
204 : )
205 : : null,
206 : ),
207 : const SizedBox(width: 16),
208 0 : Expanded(
209 0 : child: Column(
210 : crossAxisAlignment: CrossAxisAlignment.start,
211 0 : children: [
212 0 : Text(
213 0 : _member!.name,
214 : style: const TextStyle(
215 : fontSize: 20,
216 : fontWeight: FontWeight.bold,
217 : ),
218 : ),
219 : const SizedBox(height: 4),
220 0 : Text(
221 0 : _member!.email,
222 0 : style: TextStyle(color: Colors.grey[600]),
223 : ),
224 0 : if (_member!.phone != null)
225 0 : Text(
226 0 : _member!.phone!,
227 0 : style: TextStyle(color: Colors.grey[600]),
228 : ),
229 : ],
230 : ),
231 : ),
232 : ],
233 : ),
234 : ),
235 : );
236 : }
237 :
238 0 : Widget _buildMainStatsCard() {
239 0 : return Card(
240 : elevation: 2,
241 0 : shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
242 0 : child: Padding(
243 : padding: const EdgeInsets.all(16.0),
244 0 : child: Column(
245 : crossAxisAlignment: CrossAxisAlignment.start,
246 0 : children: [
247 : const Text(
248 : '주요 통계',
249 : style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
250 : ),
251 : const SizedBox(height: 16),
252 0 : Row(
253 0 : children: [
254 0 : Expanded(
255 0 : child: _buildStatItem(
256 : label: '평균 점수',
257 0 : value: _statistics!.average.toStringAsFixed(1),
258 : icon: Icons.score,
259 : color: Colors.blue,
260 : ),
261 : ),
262 0 : Expanded(
263 0 : child: _buildStatItem(
264 : label: '최고 점수',
265 0 : value: _statistics!.highScore.toString(),
266 : icon: Icons.emoji_events,
267 : color: Colors.amber,
268 : ),
269 : ),
270 : ],
271 : ),
272 : const SizedBox(height: 16),
273 0 : Row(
274 0 : children: [
275 0 : Expanded(
276 0 : child: _buildStatItem(
277 : label: '최저 점수',
278 0 : value: _statistics!.lowScore.toString(),
279 : icon: Icons.arrow_downward,
280 : color: Colors.red,
281 : ),
282 : ),
283 0 : Expanded(
284 0 : child: _buildStatItem(
285 : label: '게임 수',
286 0 : value: _statistics!.gamesPlayed.toString(),
287 : icon: Icons.sports,
288 : color: Colors.green,
289 : ),
290 : ),
291 : ],
292 : ),
293 : ],
294 : ),
295 : ),
296 : );
297 : }
298 :
299 0 : Widget _buildStatItem({
300 : required String label,
301 : required String value,
302 : required IconData icon,
303 : required Color color,
304 : }) {
305 0 : return Column(
306 0 : children: [
307 0 : Icon(icon, size: 32, color: color),
308 : const SizedBox(height: 8),
309 0 : Text(
310 : value,
311 0 : style: TextStyle(
312 : fontSize: 20,
313 : fontWeight: FontWeight.bold,
314 : color: color,
315 : ),
316 : ),
317 0 : Text(label, style: TextStyle(fontSize: 14, color: Colors.grey[600])),
318 : ],
319 : );
320 : }
321 :
322 0 : Widget _buildAdditionalStatsCard() {
323 0 : final additionalStats = _statistics!.additionalStats!;
324 :
325 0 : return Card(
326 : elevation: 2,
327 0 : shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
328 0 : child: Padding(
329 : padding: const EdgeInsets.all(16.0),
330 0 : child: Column(
331 : crossAxisAlignment: CrossAxisAlignment.start,
332 0 : children: [
333 : const Text(
334 : '추가 통계',
335 : style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
336 : ),
337 : const SizedBox(height: 16),
338 0 : ...additionalStats.entries.map((entry) {
339 0 : return Padding(
340 : padding: const EdgeInsets.only(bottom: 8.0),
341 0 : child: Row(
342 : mainAxisAlignment: MainAxisAlignment.spaceBetween,
343 0 : children: [
344 0 : Text(entry.key, style: const TextStyle(fontSize: 16)),
345 0 : Text(
346 0 : entry.value.toString(),
347 : style: const TextStyle(
348 : fontSize: 16,
349 : fontWeight: FontWeight.bold,
350 : ),
351 : ),
352 : ],
353 : ),
354 : );
355 : }),
356 : ],
357 : ),
358 : ),
359 : );
360 : }
361 :
362 0 : Widget _buildScoresTab() {
363 0 : if (_scores.isEmpty) {
364 : return const Center(child: Text('기록된 점수가 없습니다'));
365 : }
366 :
367 : // 날짜 기준으로 정렬 (최신순)
368 0 : final sortedScores = [..._scores];
369 0 : sortedScores.sort((a, b) => b.date.compareTo(a.date));
370 :
371 0 : return RefreshIndicator(
372 0 : onRefresh: _loadMemberData,
373 0 : child: ListView.builder(
374 : padding: const EdgeInsets.all(8.0),
375 0 : itemCount: sortedScores.length,
376 0 : itemBuilder: (context, index) {
377 0 : final score = sortedScores[index];
378 0 : return _buildScoreCard(score);
379 : },
380 : ),
381 : );
382 : }
383 :
384 0 : Widget _buildScoreCard(Score score) {
385 0 : final dateFormat = DateFormat('yyyy년 MM월 dd일');
386 :
387 0 : return Card(
388 : margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
389 0 : shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
390 0 : child: ExpansionTile(
391 0 : title: Row(
392 0 : children: [
393 0 : Text(
394 0 : score.totalScore.toString(),
395 : style: const TextStyle(
396 : fontSize: 20,
397 : fontWeight: FontWeight.bold,
398 : color: Colors.blue,
399 : ),
400 : ),
401 : const SizedBox(width: 16),
402 0 : Expanded(
403 0 : child: Column(
404 : crossAxisAlignment: CrossAxisAlignment.start,
405 0 : children: [
406 0 : Text(
407 0 : dateFormat.format(score.date),
408 : style: const TextStyle(fontSize: 14),
409 : ),
410 0 : if (score.notes != null && score.notes!.isNotEmpty)
411 0 : Text(
412 0 : score.notes!,
413 0 : style: TextStyle(fontSize: 12, color: Colors.grey[600]),
414 : maxLines: 1,
415 : overflow: TextOverflow.ellipsis,
416 : ),
417 : ],
418 : ),
419 : ),
420 : ],
421 : ),
422 0 : children: [
423 0 : Padding(
424 : padding: const EdgeInsets.all(16.0),
425 0 : child: Column(
426 : crossAxisAlignment: CrossAxisAlignment.start,
427 0 : children: [
428 : const Text(
429 : '프레임 점수',
430 : style: TextStyle(fontWeight: FontWeight.bold),
431 : ),
432 : const SizedBox(height: 8),
433 0 : _buildFrameScores(score.frames),
434 0 : if (score.notes != null && score.notes!.isNotEmpty) ...[
435 : const SizedBox(height: 16),
436 : const Text(
437 : '메모',
438 : style: TextStyle(fontWeight: FontWeight.bold),
439 : ),
440 : const SizedBox(height: 4),
441 0 : Text(score.notes!),
442 : ],
443 0 : const SizedBox(height: 16),
444 0 : Row(
445 : mainAxisAlignment: MainAxisAlignment.end,
446 0 : children: [
447 0 : TextButton.icon(
448 : icon: const Icon(Icons.edit, size: 16),
449 : label: const Text('수정'),
450 0 : onPressed: () {
451 : // 점수 수정 기능 (추후 구현)
452 0 : ScaffoldMessenger.of(context).showSnackBar(
453 : const SnackBar(content: Text('점수 수정 기능은 아직 개발 중입니다')),
454 : );
455 : },
456 : ),
457 : const SizedBox(width: 8),
458 0 : TextButton.icon(
459 : icon: const Icon(
460 : Icons.delete,
461 : size: 16,
462 : color: Colors.red,
463 : ),
464 : label: const Text(
465 : '삭제',
466 : style: TextStyle(color: Colors.red),
467 : ),
468 0 : onPressed: () {
469 0 : _showDeleteConfirmationDialog(score);
470 : },
471 : ),
472 : ],
473 : ),
474 : ],
475 : ),
476 : ),
477 : ],
478 : ),
479 : );
480 : }
481 :
482 0 : Widget _buildFrameScores(List<int> frames) {
483 0 : return Container(
484 : padding: const EdgeInsets.all(8),
485 0 : decoration: BoxDecoration(
486 0 : color: Colors.grey[100],
487 0 : borderRadius: BorderRadius.circular(8),
488 : ),
489 0 : child: Row(
490 0 : children: List.generate(10, (index) {
491 0 : return Expanded(
492 0 : child: Container(
493 : padding: const EdgeInsets.symmetric(vertical: 8),
494 0 : decoration: BoxDecoration(
495 0 : border: Border.all(color: Colors.grey[300]!),
496 : ),
497 0 : child: Column(
498 0 : children: [
499 0 : Text(
500 0 : '${index + 1}',
501 0 : style: TextStyle(fontSize: 10, color: Colors.grey[600]),
502 : ),
503 : const SizedBox(height: 4),
504 0 : Text(
505 0 : index < frames.length ? frames[index].toString() : '-',
506 : style: const TextStyle(fontWeight: FontWeight.bold),
507 : textAlign: TextAlign.center,
508 : ),
509 : ],
510 : ),
511 : ),
512 : );
513 : }),
514 : ),
515 : );
516 : }
517 :
518 0 : Widget _buildScoreChart() {
519 0 : if (_scores.isEmpty) {
520 : return const Center(child: Text('점수 데이터가 없습니다'));
521 : }
522 :
523 : // 날짜 기준으로 정렬 (오래된 순)
524 0 : final sortedScores = [..._scores];
525 0 : sortedScores.sort((a, b) => a.date.compareTo(b.date));
526 :
527 : // 최대 10개의 최근 점수만 표시
528 0 : final displayScores = sortedScores.length > 10
529 0 : ? sortedScores.sublist(sortedScores.length - 10)
530 : : sortedScores;
531 :
532 : // 최소/최대 점수 계산 (Y축 범위 설정용)
533 : final minScore =
534 0 : displayScores.map((s) => s.totalScore).reduce((a, b) => a < b ? a : b) -
535 : 10;
536 : final maxScore =
537 0 : displayScores.map((s) => s.totalScore).reduce((a, b) => a > b ? a : b) +
538 : 10;
539 :
540 : // 그래프 데이터 포인트 생성
541 0 : final spots = <FlSpot>[];
542 0 : for (int i = 0; i < displayScores.length; i++) {
543 0 : spots.add(FlSpot(i.toDouble(), displayScores[i].totalScore.toDouble()));
544 : }
545 :
546 0 : return LineChart(
547 0 : LineChartData(
548 0 : gridData: FlGridData(
549 : show: true,
550 : drawVerticalLine: true,
551 : horizontalInterval: 20,
552 : verticalInterval: 1,
553 : ),
554 0 : titlesData: FlTitlesData(
555 : show: true,
556 : rightTitles: const AxisTitles(
557 : sideTitles: SideTitles(showTitles: false),
558 : ),
559 : topTitles: const AxisTitles(
560 : sideTitles: SideTitles(showTitles: false),
561 : ),
562 0 : bottomTitles: AxisTitles(
563 0 : sideTitles: SideTitles(
564 : showTitles: true,
565 : reservedSize: 30,
566 : interval: 1,
567 0 : getTitlesWidget: (value, meta) {
568 0 : final index = value.toInt();
569 0 : if (index >= 0 && index < displayScores.length) {
570 0 : return Text(
571 0 : DateFormat('MM/dd').format(displayScores[index].date),
572 : style: const TextStyle(fontSize: 10),
573 : );
574 : }
575 : return const SizedBox();
576 : },
577 : ),
578 : ),
579 0 : leftTitles: AxisTitles(
580 0 : sideTitles: SideTitles(
581 : showTitles: true,
582 : interval: 50,
583 0 : getTitlesWidget: (value, meta) {
584 0 : return Text(
585 0 : value.toInt().toString(),
586 : style: const TextStyle(fontSize: 10),
587 : );
588 : },
589 : reservedSize: 40,
590 : ),
591 : ),
592 : ),
593 0 : borderData: FlBorderData(
594 : show: true,
595 0 : border: Border.all(color: Colors.grey.shade300),
596 : ),
597 : minX: 0,
598 0 : maxX: displayScores.length - 1.0,
599 0 : minY: minScore.toDouble(),
600 0 : maxY: maxScore.toDouble(),
601 0 : lineBarsData: [
602 0 : LineChartBarData(
603 : spots: spots,
604 : isCurved: false,
605 : color: Colors.blue,
606 : barWidth: 3,
607 : isStrokeCapRound: true,
608 : dotData: const FlDotData(show: true),
609 0 : belowBarData: BarAreaData(
610 : show: true,
611 0 : color: Colors.blue.withOpacity(0.2),
612 : ),
613 : ),
614 : ],
615 : ),
616 : );
617 : }
618 :
619 0 : void _showDeleteConfirmationDialog(Score score) {
620 0 : showDialog(
621 0 : context: context,
622 0 : builder: (context) => AlertDialog(
623 : title: const Text('점수 삭제'),
624 : content: const Text('이 점수 기록을 정말 삭제하시겠습니까?'),
625 0 : actions: [
626 0 : TextButton(
627 0 : onPressed: () => Navigator.of(context).pop(),
628 : child: const Text('취소'),
629 : ),
630 0 : TextButton(
631 0 : onPressed: () async {
632 0 : Navigator.of(context).pop();
633 :
634 : try {
635 0 : final scoreService = Provider.of<ScoreService>(
636 : context,
637 : listen: false,
638 : );
639 0 : await scoreService.deleteScore(score.id);
640 :
641 : // 데이터 새로고침
642 0 : _loadMemberData();
643 :
644 0 : if (mounted) {
645 0 : ScaffoldMessenger.of(
646 : context,
647 0 : ).showSnackBar(const SnackBar(content: Text('점수가 삭제되었습니다')));
648 : }
649 : } catch (e) {
650 0 : ScaffoldMessenger.of(
651 : context,
652 0 : ).showSnackBar(SnackBar(content: Text('점수 삭제에 실패했습니다: $e')));
653 : }
654 : },
655 0 : style: TextButton.styleFrom(foregroundColor: Colors.red),
656 : child: const Text('삭제'),
657 : ),
658 : ],
659 : ),
660 : );
661 : }
662 : }
|