From 094ce2292c026dce9dceb98bdb2d15dfcc950726 Mon Sep 17 00:00:00 2001 From: sihoo Date: Wed, 16 Sep 2026 14:49:48 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B1=ED=92=88=ED=83=80=EC=97=90=20?= =?UTF-8?q?=EC=9D=BC=EC=8B=9C=EC=A0=95=EC=A7=80/=EC=9E=AC=EA=B0=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 진행 중일 때 일시정지하면 그 구간은 누적 시간에서 빠지고, 재개하면 다시 이어서 잰다. 상태를 idle/running/paused 3가지로 나눠서 관리. Co-Authored-By: Claude Sonnet 5 --- lib/function/study_timer_controller.dart | 141 +++++++++++++--------- lib/ui/study_timer_screen.dart | 145 ++++++++++++++--------- 2 files changed, 178 insertions(+), 108 deletions(-) diff --git a/lib/function/study_timer_controller.dart b/lib/function/study_timer_controller.dart index 327f2c7..96f78ed 100644 --- a/lib/function/study_timer_controller.dart +++ b/lib/function/study_timer_controller.dart @@ -5,6 +5,8 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../config.dart'; +enum StudyTimerState { idle, running, paused } + class StudyTimerController extends ChangeNotifier { final String studentId; final String studentName; @@ -12,9 +14,10 @@ class StudyTimerController extends ChangeNotifier { StudyTimerController({required this.studentId, required this.studentName}); bool _isLoading = true; - bool _isBusy = false; // 시작/종료 버튼 눌러서 서버 응답 기다리는 중 - bool _isRunning = false; - DateTime? _startTime; + bool _isBusy = false; // 시작/일시정지/재개/종료 버튼 눌러서 서버 응답 기다리는 중 + StudyTimerState _state = StudyTimerState.idle; + int _elapsedSeconds = 0; // 지금 세션에서 "정지 상태"일 때 확정된 누적 초 (일시정지 구간 제외) + DateTime? _lastResumedAt; // running일 때만 의미 있음 - 여기서부터 실시간으로 흐른다 int _todaySeconds = 0; int _weekSeconds = 0; int _totalSeconds = 0; @@ -23,24 +26,25 @@ class StudyTimerController extends ChangeNotifier { bool get isLoading => _isLoading; bool get isBusy => _isBusy; - bool get isRunning => _isRunning; + bool get isRunning => _state == StudyTimerState.running; + bool get isPaused => _state == StudyTimerState.paused; + bool get isIdle => _state == StudyTimerState.idle; - /// 지금 진행 중인 세션까지 포함한 "오늘" 누적 초. 실시간으로 똑딱거린다. - 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 { + if (_state != StudyTimerState.running || _lastResumedAt == null) { + return _elapsedSeconds; + } + final diff = DateTime.now().difference(_lastResumedAt!).inSeconds; + return _elapsedSeconds + (diff > 0 ? diff : 0); } - int get liveElapsedSeconds => _liveElapsedSeconds; + /// 지금 진행 중인 세션까지 포함한 "오늘" 누적 초. running일 때만 실시간으로 늘어난다. + int get todaySecondsLive => _todaySeconds + _liveDelta; + int get weekSecondsLive => _weekSeconds + _liveDelta; + int get totalSecondsLive => _totalSeconds + _liveDelta; + + int get _liveDelta => _state == StudyTimerState.idle ? 0 : liveElapsedSeconds; void _safeNotify() { if (!_disposed) notifyListeners(); @@ -64,9 +68,10 @@ class StudyTimerController extends ChangeNotifier { ); if (response.statusCode == 200) { final data = jsonDecode(utf8.decode(response.bodyBytes)); - _isRunning = data['isRunning'] == true; - _startTime = data['startTime'] != null - ? DateTime.tryParse(data['startTime']) + _state = _parseState(data['state']); + _elapsedSeconds = (data['elapsedSeconds'] as num?)?.toInt() ?? 0; + _lastResumedAt = data['lastResumedAt'] != null + ? DateTime.tryParse(data['lastResumedAt']) : null; _todaySeconds = (data['todaySeconds'] as num?)?.toInt() ?? 0; _weekSeconds = (data['weekSeconds'] as num?)?.toInt() ?? 0; @@ -81,9 +86,20 @@ class StudyTimerController extends ChangeNotifier { } } + StudyTimerState _parseState(dynamic raw) { + switch (raw) { + case 'running': + return StudyTimerState.running; + case 'paused': + return StudyTimerState.paused; + default: + return StudyTimerState.idle; + } + } + void _restartTickerIfNeeded() { _ticker?.cancel(); - if (_isRunning) { + if (_state == StudyTimerState.running) { _ticker = Timer.periodic( const Duration(seconds: 1), (_) => _safeNotify(), @@ -92,50 +108,67 @@ class StudyTimerController extends ChangeNotifier { } 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(); - } + return _action( + '/api/study/start', + body: {"studentId": studentId, "studentName": studentName}, + onSuccess: (_) async { + await _fetchStatus(); + return '공부를 시작했습니다.'; + }, + ); + } + + Future<(bool success, String message)> pause() async { + return _action( + '/api/study/pause', + onSuccess: (_) async { + await _fetchStatus(); + return '일시정지했습니다.'; + }, + ); + } + + Future<(bool success, String message)> resume() async { + return _action( + '/api/study/resume', + onSuccess: (_) async { + await _fetchStatus(); + return '다시 시작했습니다.'; + }, + ); } Future<(bool success, String message)> stop() async { + return _action( + '/api/study/stop', + onSuccess: (result) async { + await _fetchStatus(); // 오늘/이번주/전체 합계를 서버 값으로 다시 맞춘다. + final minutes = + ((result['durationSeconds'] as num?)?.toInt() ?? 0) ~/ 60; + return '이번 공부 시간: $minutes분 기록 완료!'; + }, + ); + } + + Future<(bool success, String message)> _action( + String path, { + Map body = const {}, + required Future Function(Map result) onSuccess, + }) async { _isBusy = true; _safeNotify(); try { final response = await http.post( - Uri.parse('$baseUrl/api/study/stop'), + Uri.parse('$baseUrl$path'), headers: {"Content-Type": "application/json"}, - body: jsonEncode({"studentId": studentId}), + body: jsonEncode({"studentId": studentId, ...body}), ); 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분 기록 완료!'); + final message = await onSuccess(result); + return (true, message); } - return (false, '${result['message'] ?? '공부 종료 실패'}'); + return (false, '${result['message'] ?? '처리 실패'}'); } catch (e) { return (false, '네트워크 에러: $e'); } finally { diff --git a/lib/ui/study_timer_screen.dart b/lib/ui/study_timer_screen.dart index 0050c2a..a97dcd0 100644 --- a/lib/ui/study_timer_screen.dart +++ b/lib/ui/study_timer_screen.dart @@ -60,10 +60,10 @@ class _StudyTimerScreenState extends State { return '$m분'; } - Future _toggle() async { - final (success, message) = _controller.isRunning - ? await _controller.stop() - : await _controller.start(); + Future _runAction( + Future<(bool success, String message)> Function() action, + ) async { + final (success, message) = await action(); if (!mounted) return; AppNotice.show( context, @@ -92,6 +92,7 @@ class _StudyTimerScreenState extends State { listenable: _controller, builder: (context, _) { final bool isRunning = _controller.isRunning; + final bool isPaused = _controller.isPaused; return Scaffold( backgroundColor: AppPalette.mist, appBar: AppBar( @@ -117,7 +118,11 @@ class _StudyTimerScreenState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - isRunning ? '지금 공부 중이에요' : '공부를 시작해볼까요?', + isRunning + ? '지금 공부 중이에요' + : isPaused + ? '일시정지 중이에요' + : '공부를 시작해볼까요?', style: const TextStyle( fontSize: 15, color: Colors.grey, @@ -126,60 +131,14 @@ class _StudyTimerScreenState extends State { const SizedBox(height: 12), Text( _formatHMS(_controller.liveElapsedSeconds), - style: const TextStyle( + style: TextStyle( fontSize: 52, fontWeight: FontWeight.bold, - color: AppPalette.ink, + color: isPaused ? Colors.grey : 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, - ), - ), - ], - ), - ), - ), - ), + _buildActionButtons(isRunning, isPaused), const SizedBox(height: 40), Row( mainAxisAlignment: MainAxisAlignment.center, @@ -209,6 +168,84 @@ class _StudyTimerScreenState extends State { ); } + Widget _buildActionButtons(bool isRunning, bool isPaused) { + if (!isRunning && !isPaused) { + return _circleButton( + size: 140, + icon: Icons.play_arrow_rounded, + label: '공부 시작', + color: AppPalette.ink, + onTap: () => _runAction(_controller.start), + ); + } + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _circleButton( + size: 110, + icon: isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded, + label: isPaused ? '재개' : '일시정지', + color: isPaused ? AppPalette.ink : Colors.amber[700]!, + onTap: () => + _runAction(isPaused ? _controller.resume : _controller.pause), + ), + const SizedBox(width: 20), + _circleButton( + size: 110, + icon: Icons.stop_rounded, + label: '공부 종료', + color: Colors.red[400]!, + onTap: () => _runAction(_controller.stop), + ), + ], + ); + } + + Widget _circleButton({ + required double size, + required IconData icon, + required String label, + required Color color, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: _controller.isBusy ? null : onTap, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + boxShadow: [ + BoxShadow( + color: color.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(icon, color: Colors.white, size: size > 120 ? 36 : 28), + Text( + label, + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: size > 120 ? 14 : 12.5, + ), + ), + ], + ), + ), + ), + ); + } + Widget _statBox(String label, String value) { return Container( width: 100,