diff --git a/lib/function/login_controller.dart b/lib/function/login_controller.dart index 9e95b97..2cbbbe4 100644 --- a/lib/function/login_controller.dart +++ b/lib/function/login_controller.dart @@ -16,18 +16,21 @@ class LoginResult { final String? name; final bool isDeviceMatched; final String? errorMessage; + final int? grade; // ๐ŸŽ“ ๊ณต๋ถ€ ํƒ€์ด๋จธ ํ•™๋…„๋ณ„ ๋žญํ‚น์—์„œ ๋ณธ์ธ ํ•™๋…„์„ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์“ฐ๊ธฐ ์œ„ํ•จ. const LoginResult.masterSuccess({required this.studentId, required this.name}) : outcome = LoginOutcome.masterSuccess, role = null, isDeviceMatched = true, - errorMessage = null; + errorMessage = null, + grade = null; const LoginResult.needsPasswordChange({ required this.studentId, required this.name, required this.role, required this.isDeviceMatched, + this.grade, }) : outcome = LoginOutcome.needsPasswordChange, errorMessage = null; @@ -36,6 +39,7 @@ class LoginResult { required this.studentId, required this.name, required this.isDeviceMatched, + this.grade, }) : outcome = LoginOutcome.success, errorMessage = null; @@ -44,7 +48,8 @@ class LoginResult { role = null, studentId = null, name = null, - isDeviceMatched = false; + isDeviceMatched = false, + grade = null; } class LoginController extends ChangeNotifier { @@ -101,6 +106,7 @@ class LoginController extends ChangeNotifier { String name = ''; String studentId = ''; int isFirstLogin = 0; + int? grade; var userObj = resData['user']; if (userObj != null) { @@ -114,6 +120,7 @@ class LoginController extends ChangeNotifier { userObj['studentId']?.toString() ?? userObj['student_id']?.toString() ?? id; + grade = int.tryParse(userObj['grade']?.toString() ?? ''); var rawFirst = userObj['isFirstLogin'] ?? userObj['is_first_login']; isFirstLogin = (rawFirst is bool) @@ -126,6 +133,7 @@ class LoginController extends ChangeNotifier { resData['studentName']?.toString() ?? ''; studentId = resData['studentId']?.toString() ?? id; + grade = int.tryParse(resData['grade']?.toString() ?? ''); var rawFirst = resData['isFirstLogin'] ?? resData['is_first_login']; isFirstLogin = (rawFirst is bool) @@ -153,6 +161,7 @@ class LoginController extends ChangeNotifier { name: name, role: role, isDeviceMatched: isDeviceMatched, + grade: grade, ); } @@ -161,6 +170,7 @@ class LoginController extends ChangeNotifier { studentId: studentId, name: name, isDeviceMatched: isDeviceMatched, + grade: grade, ); } else { String errorMsg = '๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'; diff --git a/lib/function/session_store.dart b/lib/function/session_store.dart index 42b61a0..a3a3348 100644 --- a/lib/function/session_store.dart +++ b/lib/function/session_store.dart @@ -9,12 +9,14 @@ class SavedSession { final String userName; final String role; final bool isDeviceMatched; + final int? grade; // ๐ŸŽ“ ๊ณต๋ถ€ ํƒ€์ด๋จธ ํ•™๋…„๋ณ„ ๋žญํ‚น์—์„œ ๋ณธ์ธ ํ•™๋…„์„ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์“ฐ๊ธฐ ์œ„ํ•จ. const SavedSession({ required this.userId, required this.userName, required this.role, required this.isDeviceMatched, + this.grade, }); Map toJson() => { @@ -22,6 +24,7 @@ class SavedSession { 'userName': userName, 'role': role, 'isDeviceMatched': isDeviceMatched, + 'grade': grade, }; factory SavedSession.fromJson(Map json) => SavedSession( @@ -29,6 +32,7 @@ class SavedSession { userName: json['userName'] as String, role: json['role'] as String, isDeviceMatched: json['isDeviceMatched'] as bool? ?? true, + grade: json['grade'] as int?, ); } diff --git a/lib/function/study_ranking_controller.dart b/lib/function/study_ranking_controller.dart new file mode 100644 index 0000000..13c5f25 --- /dev/null +++ b/lib/function/study_ranking_controller.dart @@ -0,0 +1,46 @@ +// ๐Ÿ† ํ•™๋…„๋ณ„ ๊ณต๋ถ€์‹œ๊ฐ„ ๋žญํ‚น ํ™”๋ฉด์˜ ๊ธฐ๋Šฅ(์„œ๋ฒ„ ํ†ต์‹ /์ƒํƒœ) ๋‹ด๋‹น ์ปจํŠธ๋กค๋Ÿฌ. +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../config.dart'; + +class StudyRankingController extends ChangeNotifier { + final int grade; + + StudyRankingController({required this.grade}); + + bool _isLoading = true; + String _period = 'today'; // today / week / total + List _ranking = []; + + bool get isLoading => _isLoading; + String get period => _period; + List get ranking => _ranking; + + Future init() => fetchRanking(); + + Future setPeriod(String period) async { + if (_period == period) return; + _period = period; + await fetchRanking(); + } + + Future fetchRanking() async { + _isLoading = true; + notifyListeners(); + try { + final response = await http.get( + Uri.parse('$baseUrl/api/study/ranking?grade=$grade&period=$_period'), + ); + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + _ranking = data['ranking'] ?? []; + } + } catch (_) { + // ์กฐ์šฉํžˆ ๋ฌด์‹œ - ๋งˆ์ง€๋ง‰์œผ๋กœ ๋ฐ›์•„์˜จ ๋žญํ‚น์„ ์œ ์ง€ํ•œ๋‹ค. + } finally { + _isLoading = false; + notifyListeners(); + } + } +} diff --git a/lib/function/study_timer_controller.dart b/lib/function/study_timer_controller.dart new file mode 100644 index 0000000..ecd0fa4 --- /dev/null +++ b/lib/function/study_timer_controller.dart @@ -0,0 +1,146 @@ +// โฑ๏ธ ์šฐ๋ฆฌ ํ•™๊ต ์ „์šฉ "์—ดํ’ˆํƒ€" ๊ณต๋ถ€ ํƒ€์ด๋จธ ํ™”๋ฉด์˜ ๊ธฐ๋Šฅ(์„œ๋ฒ„ ํ†ต์‹ /์ƒํƒœ) ๋‹ด๋‹น ์ปจํŠธ๋กค๋Ÿฌ. +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../config.dart'; + +class StudyTimerController extends ChangeNotifier { + final String studentId; + final String studentName; + + StudyTimerController({required this.studentId, required this.studentName}); + + bool _isLoading = true; + bool _isBusy = false; // ์‹œ์ž‘/์ข…๋ฃŒ ๋ฒ„ํŠผ ๋ˆŒ๋Ÿฌ์„œ ์„œ๋ฒ„ ์‘๋‹ต ๊ธฐ๋‹ค๋ฆฌ๋Š” ์ค‘ + bool _isRunning = false; + DateTime? _startTime; + int _todaySeconds = 0; + int _weekSeconds = 0; + int _totalSeconds = 0; + Timer? _ticker; + bool _disposed = false; + + bool get isLoading => _isLoading; + bool get isBusy => _isBusy; + bool get isRunning => _isRunning; + + /// ์ง€๊ธˆ ์ง„ํ–‰ ์ค‘์ธ ์„ธ์…˜๊นŒ์ง€ ํฌํ•จํ•œ "์˜ค๋Š˜" ๋ˆ„์  ์ดˆ. ์‹ค์‹œ๊ฐ„์œผ๋กœ ๋˜‘๋”ฑ๊ฑฐ๋ฆฐ๋‹ค. + int get todaySecondsLive => + _todaySeconds + (_isRunning ? _liveElapsedSeconds : 0); + int get weekSecondsLive => + _weekSeconds + (_isRunning ? _liveElapsedSeconds : 0); + int get totalSecondsLive => + _totalSeconds + (_isRunning ? _liveElapsedSeconds : 0); + + /// ์ง€๊ธˆ ์„ธ์…˜ ํ•˜๋‚˜๋งŒ์˜ ๊ฒฝ๊ณผ ์‹œ๊ฐ„(ํƒ€์ด๋จธ ํ™”๋ฉด ํฐ ์ˆซ์ž์šฉ). + int get _liveElapsedSeconds { + if (_startTime == null) return 0; + final diff = DateTime.now().difference(_startTime!).inSeconds; + return diff > 0 ? diff : 0; + } + + int get liveElapsedSeconds => _liveElapsedSeconds; + + void _safeNotify() { + if (!_disposed) notifyListeners(); + } + + Future init() async { + await _fetchStatus(); + } + + @override + void dispose() { + _disposed = true; + _ticker?.cancel(); + super.dispose(); + } + + Future _fetchStatus() async { + try { + final response = await http.get( + Uri.parse('$baseUrl/api/study/status?studentId=$studentId'), + ); + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + _isRunning = data['isRunning'] == true; + _startTime = data['startTime'] != null + ? DateTime.tryParse(data['startTime']) + : null; + _todaySeconds = (data['todaySeconds'] as num?)?.toInt() ?? 0; + _weekSeconds = (data['weekSeconds'] as num?)?.toInt() ?? 0; + _totalSeconds = (data['totalSeconds'] as num?)?.toInt() ?? 0; + _restartTickerIfNeeded(); + } + } catch (_) { + // ์กฐ์šฉํžˆ ๋ฌด์‹œ - ๋งˆ์ง€๋ง‰์œผ๋กœ ์•Œ๋˜ ์ƒํƒœ๋ฅผ ์œ ์ง€ํ•œ๋‹ค. + } finally { + _isLoading = false; + _safeNotify(); + } + } + + void _restartTickerIfNeeded() { + _ticker?.cancel(); + if (_isRunning) { + _ticker = Timer.periodic( + const Duration(seconds: 1), + (_) => _safeNotify(), + ); + } + } + + Future<(bool success, String message)> start() async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/study/start'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"studentId": studentId, "studentName": studentName}), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + _isRunning = true; + _startTime = DateTime.tryParse(result['startTime']); + _restartTickerIfNeeded(); + return (true, '๊ณต๋ถ€๋ฅผ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + return (false, '${result['message'] ?? '๊ณต๋ถ€ ์‹œ์ž‘ ์‹คํŒจ'}'); + } catch (e) { + return (false, '๋„คํŠธ์›Œํฌ ์—๋Ÿฌ: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } + + Future<(bool success, String message)> stop() async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/study/stop'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"studentId": studentId}), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + _isRunning = false; + _startTime = null; + _ticker?.cancel(); + await _fetchStatus(); // ์˜ค๋Š˜/์ด๋ฒˆ์ฃผ/์ „์ฒด ํ•ฉ๊ณ„๋ฅผ ์„œ๋ฒ„ ๊ฐ’์œผ๋กœ ๋‹ค์‹œ ๋งž์ถ˜๋‹ค. + final minutes = + ((result['durationSeconds'] as num?)?.toInt() ?? 0) ~/ 60; + return (true, '์ด๋ฒˆ ๊ณต๋ถ€ ์‹œ๊ฐ„: $minutes๋ถ„ ๊ธฐ๋ก ์™„๋ฃŒ!'); + } + return (false, '${result['message'] ?? '๊ณต๋ถ€ ์ข…๋ฃŒ ์‹คํŒจ'}'); + } catch (e) { + return (false, '๋„คํŠธ์›Œํฌ ์—๋Ÿฌ: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } +} diff --git a/lib/main_client_app.dart b/lib/main_client_app.dart index 8aa21e0..5c43740 100644 --- a/lib/main_client_app.dart +++ b/lib/main_client_app.dart @@ -107,6 +107,7 @@ class _StartupGateState extends State<_StartupGate> { userName: session.userName, role: session.role, isDeviceMatched: session.isDeviceMatched, + grade: session.grade, ); } return const LoginScreen(); diff --git a/lib/ui/login_screen.dart b/lib/ui/login_screen.dart index 9395d1d..259626a 100644 --- a/lib/ui/login_screen.dart +++ b/lib/ui/login_screen.dart @@ -78,6 +78,7 @@ class _LoginScreenState extends State { result.name!, result.role!, result.isDeviceMatched, + result.grade, ); break; case LoginOutcome.success: @@ -87,6 +88,7 @@ class _LoginScreenState extends State { result.studentId!, result.name!, result.isDeviceMatched, + result.grade, ); break; } @@ -98,6 +100,7 @@ class _LoginScreenState extends State { String name, String role, bool isDeviceMatched, + int? grade, ) { final TextEditingController newPwController = TextEditingController(); @@ -168,6 +171,7 @@ class _LoginScreenState extends State { studentId, name, isDeviceMatched, + grade, ); } else { AppNotice.show(context, message); @@ -189,6 +193,7 @@ class _LoginScreenState extends State { String studentId, String name, bool isDeviceMatched, + int? grade, ) async { if (_keepLoggedIn) { await SessionStore.save( @@ -197,6 +202,7 @@ class _LoginScreenState extends State { userName: name, role: role, isDeviceMatched: isDeviceMatched, + grade: grade, ), ); } else { @@ -211,6 +217,7 @@ class _LoginScreenState extends State { userName: name, role: role, isDeviceMatched: isDeviceMatched, + grade: grade, ), ), ); diff --git a/lib/ui/main_dashboard.dart b/lib/ui/main_dashboard.dart index 31037f2..2673e2f 100644 --- a/lib/ui/main_dashboard.dart +++ b/lib/ui/main_dashboard.dart @@ -19,6 +19,7 @@ import 'launchpad_transition.dart'; import 'login_screen.dart'; import 'nfc_poccket_checkin_screen.dart'; import 'nfc_tag_writer_screen.dart'; +import 'study_timer_screen.dart'; import 'teacher_attendance_page.dart'; import 'teacher_student_management_page.dart'; @@ -27,6 +28,7 @@ class MainDashboard extends StatefulWidget { final String? userName; final String role; // 'student' | 'teacher' | 'admin' final bool isDeviceMatched; + final int? grade; // ๐ŸŽ“ ๊ณต๋ถ€ ํƒ€์ด๋จธ ํ•™๋…„๋ณ„ ๋žญํ‚น์—์„œ ๋ณธ์ธ ํ•™๋…„์„ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์“ฐ๊ธฐ ์œ„ํ•จ. const MainDashboard({ super.key, @@ -34,6 +36,7 @@ class MainDashboard extends StatefulWidget { this.userName, required this.role, this.isDeviceMatched = true, + this.grade, }); @override @@ -312,6 +315,23 @@ class _MainDashboardState extends State { ), ), )); + tiles.add(( + id: 'study_timer', + child: _buildModernCard( + icon: Icons.timer_rounded, + title: '๊ณต๋ถ€ ํƒ€์ด๋จธ', + subtitle: '์—ดํ’ˆํƒ€ | ํ•™๋…„๋ณ„ ๊ณต๋ถ€ ๋žญํ‚น', + color: AppPalette.ink, + onTap: () => pushLaunchpad( + context, + (context) => StudyTimerScreen( + studentId: _displayId, + studentName: _displayName, + grade: widget.grade, + ), + ), + ), + )); } if (_isTeacherOrAbove) { diff --git a/lib/ui/study_ranking_screen.dart b/lib/ui/study_ranking_screen.dart new file mode 100644 index 0000000..c8bc434 --- /dev/null +++ b/lib/ui/study_ranking_screen.dart @@ -0,0 +1,193 @@ +// ๐Ÿ† ํ•™๋…„๋ณ„ ๊ณต๋ถ€์‹œ๊ฐ„ ๋žญํ‚น ํ™”๋ฉด (UI ์ „์šฉ). +// ์„œ๋ฒ„ ํ†ต์‹ /์ƒํƒœ๋Š” lib/function/study_ranking_controller.dart๊ฐ€ ๋‹ด๋‹นํ•œ๋‹ค. +import 'package:flutter/material.dart'; +import '../function/study_ranking_controller.dart'; +import '../theme/app_palette.dart'; +import 'title_pill.dart'; + +class StudyRankingScreen extends StatefulWidget { + final int grade; + final String myStudentId; + + const StudyRankingScreen({ + super.key, + required this.grade, + required this.myStudentId, + }); + + @override + State createState() => _StudyRankingScreenState(); +} + +class _StudyRankingScreenState extends State { + late final StudyRankingController _controller; + + @override + void initState() { + super.initState(); + _controller = StudyRankingController(grade: widget.grade); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + String _formatHM(int totalSeconds) { + final h = totalSeconds ~/ 3600; + final m = (totalSeconds % 3600) ~/ 60; + if (h > 0) return '$h์‹œ๊ฐ„ $m๋ถ„'; + return '$m๋ถ„'; + } + + Color? _rankColor(int rank) { + switch (rank) { + case 1: + return const Color(0xFFC9A227); // ๊ธˆ + case 2: + return const Color(0xFF9AA0A6); // ์€ + case 3: + return const Color(0xFFB07A4B); // ๋™ + default: + return null; + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final ranking = _controller.ranking; + return Scaffold( + backgroundColor: AppPalette.mist, + appBar: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: AppPalette.ink, + elevation: 0, + centerTitle: true, + title: TitlePill('${widget.grade}ํ•™๋…„ ๊ณต๋ถ€ ๋žญํ‚น'), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + children: [ + _periodChip('today', '์˜ค๋Š˜'), + const SizedBox(width: 8), + _periodChip('week', '์ด๋ฒˆ์ฃผ'), + const SizedBox(width: 8), + _periodChip('total', '์ „์ฒด'), + ], + ), + ), + Expanded( + child: _controller.isLoading + ? const Center(child: CircularProgressIndicator()) + : ranking.isEmpty + ? const Center( + child: Text( + '์•„์ง ๊ธฐ๋ก์ด ์—†์–ด์š”.\n๊ณต๋ถ€ ํƒ€์ด๋จธ๋ฅผ ๋ˆŒ๋Ÿฌ 1๋“ฑ์— ๋„์ „ํ•ด๋ณด์„ธ์š”.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + itemCount: ranking.length, + itemBuilder: (context, index) { + final r = ranking[index]; + final bool isMe = + r['studentId'] == widget.myStudentId; + final int rank = (r['rank'] as num).toInt(); + final medalColor = _rankColor(rank); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: isMe ? AppPalette.ink : AppPalette.paper, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isMe ? AppPalette.ink : AppPalette.sage, + ), + ), + child: Row( + children: [ + SizedBox( + width: 32, + child: medalColor != null + ? Icon( + Icons.emoji_events_rounded, + color: medalColor, + size: 22, + ) + : Text( + '$rank', + style: TextStyle( + fontWeight: FontWeight.bold, + color: isMe + ? Colors.white + : AppPalette.ink, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + r['name'] ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.bold, + color: isMe + ? Colors.white + : AppPalette.ink, + ), + ), + ), + Text( + _formatHM((r['totalSeconds'] as num).toInt()), + style: TextStyle( + fontWeight: FontWeight.bold, + color: isMe ? Colors.white : AppPalette.ink, + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _periodChip(String value, String label) { + final bool selected = _controller.period == value; + return Expanded( + child: ChoiceChip( + label: Center(child: Text(label)), + selected: selected, + onSelected: (_) => _controller.setPeriod(value), + selectedColor: AppPalette.ink, + labelStyle: TextStyle( + color: selected ? Colors.white : AppPalette.ink, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} diff --git a/lib/ui/study_timer_screen.dart b/lib/ui/study_timer_screen.dart new file mode 100644 index 0000000..3c9f48a --- /dev/null +++ b/lib/ui/study_timer_screen.dart @@ -0,0 +1,233 @@ +// โฑ๏ธ ์šฐ๋ฆฌ ํ•™๊ต ์ „์šฉ "์—ดํ’ˆํƒ€" ๊ณต๋ถ€ ํƒ€์ด๋จธ ํ™”๋ฉด (UI ์ „์šฉ). +// ์„œ๋ฒ„ ํ†ต์‹ /์ƒํƒœ๋Š” lib/function/study_timer_controller.dart๊ฐ€ ๋‹ด๋‹นํ•œ๋‹ค. +import 'package:flutter/material.dart'; +import '../function/study_timer_controller.dart'; +import '../theme/app_palette.dart'; +import 'app_notice.dart'; +import 'launchpad_transition.dart'; +import 'study_ranking_screen.dart'; +import 'title_pill.dart'; + +class StudyTimerScreen extends StatefulWidget { + final String studentId; + final String studentName; + final int? grade; + + const StudyTimerScreen({ + super.key, + required this.studentId, + required this.studentName, + this.grade, + }); + + @override + State createState() => _StudyTimerScreenState(); +} + +class _StudyTimerScreenState extends State { + late final StudyTimerController _controller; + + @override + void initState() { + super.initState(); + _controller = StudyTimerController( + studentId: widget.studentId, + studentName: widget.studentName, + ); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + String _formatHMS(int totalSeconds) { + final h = totalSeconds ~/ 3600; + final m = (totalSeconds % 3600) ~/ 60; + final s = totalSeconds % 60; + final hh = h.toString().padLeft(2, '0'); + final mm = m.toString().padLeft(2, '0'); + final ss = s.toString().padLeft(2, '0'); + return '$hh:$mm:$ss'; + } + + String _formatHM(int totalSeconds) { + final h = totalSeconds ~/ 3600; + final m = (totalSeconds % 3600) ~/ 60; + if (h > 0) return '$h์‹œ๊ฐ„ $m๋ถ„'; + return '$m๋ถ„'; + } + + Future _toggle() async { + final (success, message) = _controller.isRunning + ? await _controller.stop() + : await _controller.start(); + if (!mounted) return; + AppNotice.show( + context, + message, + icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded, + ); + } + + void _openRanking() { + if (widget.grade == null) { + AppNotice.show(context, 'ํ•™๋…„ ์ •๋ณด๊ฐ€ ์—†์–ด ๋žญํ‚น์„ ๋ณผ ์ˆ˜ ์—†์–ด์š”.'); + return; + } + pushLaunchpad( + context, + (context) => StudyRankingScreen( + grade: widget.grade!, + myStudentId: widget.studentId, + ), + ); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final bool isRunning = _controller.isRunning; + return Scaffold( + backgroundColor: AppPalette.mist, + appBar: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: AppPalette.ink, + elevation: 0, + centerTitle: true, + title: const TitlePill('๊ณต๋ถ€ ํƒ€์ด๋จธ'), + actions: [ + IconButton( + icon: const Icon(Icons.leaderboard_rounded), + tooltip: 'ํ•™๋…„ ๋žญํ‚น', + onPressed: _openRanking, + ), + ], + ), + body: _controller.isLoading + ? const Center(child: CircularProgressIndicator()) + : Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isRunning ? '์ง€๊ธˆ ๊ณต๋ถ€ ์ค‘์ด์—์š”' : '๊ณต๋ถ€๋ฅผ ์‹œ์ž‘ํ•ด๋ณผ๊นŒ์š”?', + style: const TextStyle( + fontSize: 15, + color: Colors.grey, + ), + ), + const SizedBox(height: 12), + Text( + _formatHMS(_controller.liveElapsedSeconds), + style: const TextStyle( + fontSize: 52, + fontWeight: FontWeight.bold, + color: AppPalette.ink, + ), + ), + const SizedBox(height: 36), + GestureDetector( + onTap: _controller.isBusy ? null : _toggle, + child: Container( + width: 140, + height: 140, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isRunning + ? Colors.red[400] + : AppPalette.ink, + boxShadow: [ + BoxShadow( + color: + (isRunning ? Colors.red : AppPalette.ink) + .withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Center( + child: _controller.isBusy + ? const CircularProgressIndicator( + color: Colors.white, + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isRunning + ? Icons.stop_rounded + : Icons.play_arrow_rounded, + color: Colors.white, + size: 36, + ), + Text( + isRunning ? '๊ณต๋ถ€ ์ข…๋ฃŒ' : '๊ณต๋ถ€ ์‹œ์ž‘', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 40), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _statBox( + '์˜ค๋Š˜', + _formatHM(_controller.todaySecondsLive), + ), + const SizedBox(width: 12), + _statBox( + '์ด๋ฒˆ์ฃผ', + _formatHM(_controller.weekSecondsLive), + ), + const SizedBox(width: 12), + _statBox( + '์ „์ฒด', + _formatHM(_controller.totalSecondsLive), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } + + Widget _statBox(String label, String value) { + return Container( + width: 100, + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppPalette.sage), + ), + child: Column( + children: [ + Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 12)), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + ), + ], + ), + ); + } +}