Files
school-attendance/lib/ui/study_timer_screen.dart
T
sihooandClaude Sonnet 5 d183370cca 뽀모도로 공부/휴식 시간 커스텀 설정을 친구들과 공유하는 기능 추가
내가 쓰는 공부/휴식 시간 조합에 이름을 붙여 공유하고, 다른 학생이
올린 루틴을 목록에서 골라 그대로 적용할 수 있다. 인기순/최신순 정렬,
많이 쓰인 루틴일수록 사용 횟수가 올라가서 눈에 띄게 했다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 00:15:28 +09:00

492 lines
17 KiB
Dart

// ⏱️ 우리 학교 전용 "백품타" 공부 타이머 화면 (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_preset_screen.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<StudyTimerScreen> createState() => _StudyTimerScreenState();
}
class _StudyTimerScreenState extends State<StudyTimerScreen> {
late final StudyTimerController _controller;
@override
void initState() {
super.initState();
_controller = StudyTimerController(
studentId: widget.studentId,
studentName: widget.studentName,
);
_controller.onPhaseChanged = _handlePhaseChanged;
_controller.init();
}
void _handlePhaseChanged(String message) {
if (!mounted) return;
AppNotice.show(context, message, icon: Icons.timer_rounded);
}
@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 _formatMinSec(int totalSeconds) {
final s = totalSeconds < 0 ? 0 : totalSeconds;
final m = s ~/ 60;
final sec = s % 60;
return '$m:${sec.toString().padLeft(2, '0')}';
}
String _statusLabel(bool isRunning, bool isPaused) {
if (_controller.pomodoroEnabled && _controller.phase != null) {
return _controller.phase == PomodoroPhase.study
? '뽀모도로 - 공부 중이에요'
: '뽀모도로 - 쉬는 중이에요';
}
if (isRunning) return '지금 공부 중이에요';
if (isPaused) return '일시정지 중이에요';
return '공부를 시작해볼까요?';
}
String _formatHM(int totalSeconds) {
final h = totalSeconds ~/ 3600;
final m = (totalSeconds % 3600) ~/ 60;
if (h > 0) return '$h시간 $m분';
return '$m분';
}
Future<void> _runAction(
Future<(bool success, String message)> Function() action,
) async {
final (success, message) = await action();
if (!mounted) return;
AppNotice.show(
context,
message,
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
);
}
Future<void> _showPomodoroSettings() async {
bool enabled = _controller.pomodoroEnabled;
int studyMinutes = _controller.studyMinutes;
int breakMinutes = _controller.breakMinutes;
final bool canEdit = _controller.isIdle;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: AppPalette.paper,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
return StatefulBuilder(
builder: (sheetContext, setSheetState) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Expanded(
child: Text(
'뽀모도로 모드',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
Switch(
value: enabled,
activeThumbColor: AppPalette.ink,
onChanged: canEdit
? (v) => setSheetState(() => enabled = v)
: null,
),
],
),
const Text(
'공부/휴식 시간을 정해두면, 공부 시간이 끝났을 때 자동으로\n일시정지되고 휴식이 끝나면 자동으로 다시 시작해요.',
style: TextStyle(color: Colors.grey, fontSize: 12.5),
),
if (!canEdit) ...[
const SizedBox(height: 10),
const Text(
'타이머가 멈춰있을 때만 설정을 바꿀 수 있어요.',
style: TextStyle(color: Colors.redAccent, fontSize: 12.5),
),
],
const SizedBox(height: 18),
_minuteStepper(
label: '공부 시간',
minutes: studyMinutes,
enabled: canEdit,
onChanged: (v) => setSheetState(() => studyMinutes = v),
),
const SizedBox(height: 12),
_minuteStepper(
label: '휴식 시간',
minutes: breakMinutes,
enabled: canEdit,
onChanged: (v) => setSheetState(() => breakMinutes = v),
),
const SizedBox(height: 10),
OutlinedButton.icon(
icon: const Icon(Icons.groups_rounded, size: 18),
label: const Text('친구들이 공유한 루틴 보기 / 공유하기'),
onPressed: () async {
final result = await pushLaunchpad<Map<String, int>>(
sheetContext,
(context) => StudyPresetScreen(
studentId: widget.studentId,
studentName: widget.studentName,
initialStudyMinutes: studyMinutes,
initialBreakMinutes: breakMinutes,
),
);
if (result == null) return;
if (!canEdit) {
if (sheetContext.mounted) {
AppNotice.show(
sheetContext,
'타이머가 멈춰있을 때만 루틴을 적용할 수 있어요.',
);
}
return;
}
setSheetState(() {
studyMinutes = result['studyMinutes']!;
breakMinutes = result['breakMinutes']!;
enabled = true;
});
},
),
const SizedBox(height: 18),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: !canEdit
? null
: () async {
await _controller.setPomodoroSettings(
enabled: enabled,
studyMinutes: studyMinutes,
breakMinutes: breakMinutes,
);
if (sheetContext.mounted) {
Navigator.of(sheetContext).pop();
}
},
child: const Text('저장'),
),
],
),
);
},
);
},
);
}
Widget _minuteStepper({
required String label,
required int minutes,
required bool enabled,
required ValueChanged<int> onChanged,
}) {
return Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
IconButton(
icon: const Icon(Icons.remove_circle_outline_rounded),
onPressed: enabled && minutes > 5
? () => onChanged(minutes - 5)
: null,
),
SizedBox(
width: 48,
child: Text(
'$minutes분',
textAlign: TextAlign.center,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline_rounded),
onPressed: enabled && minutes < 120
? () => onChanged(minutes + 5)
: null,
),
],
);
}
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;
final bool isPaused = _controller.isPaused;
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.tune_rounded),
tooltip: '뽀모도로 설정',
onPressed: _showPomodoroSettings,
),
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(
_statusLabel(isRunning, isPaused),
style: const TextStyle(
fontSize: 15,
color: Colors.grey,
),
),
if (_controller.pomodoroEnabled &&
_controller.phase != null) ...[
const SizedBox(height: 4),
Text(
'${_controller.phase == PomodoroPhase.study ? '다음 휴식까지' : '다음 공부까지'} '
'${_formatMinSec(_controller.phaseSecondsLeft)} · '
'완료 ${_controller.completedCycles}회',
style: const TextStyle(
fontSize: 12.5,
color: Colors.grey,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: 12),
Text(
_formatHMS(_controller.liveElapsedSeconds),
style: TextStyle(
fontSize: 52,
fontWeight: FontWeight.bold,
color: isPaused ? Colors.grey : AppPalette.ink,
),
),
const SizedBox(height: 36),
_buildActionButtons(isRunning, isPaused),
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 _buildActionButtons(bool isRunning, bool isPaused) {
if (!isRunning && !isPaused) {
return _circleButton(
size: 140,
icon: Icons.play_arrow_rounded,
label: _controller.pomodoroEnabled ? '뽀모도로 시작' : '공부 시작',
color: AppPalette.ink,
onTap: () => _runAction(_controller.start),
);
}
if (_controller.pomodoroEnabled && _controller.phase != null) {
// 뽀모도로 자동 전환 중에는 일시정지/재개는 자동으로 일어나니 종료 버튼만 보여준다.
return _circleButton(
size: 140,
icon: Icons.stop_rounded,
label: '공부 종료',
color: Colors.red[400]!,
onTap: () => _runAction(_controller.stop),
);
}
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,
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),
),
],
),
);
}
}