From d183370cca90d3c2e85396b6a10767ddb4069979 Mon Sep 17 00:00:00 2001 From: sihoo Date: Thu, 17 Sep 2026 00:15:28 +0900 Subject: [PATCH] =?UTF-8?q?=EB=BD=80=EB=AA=A8=EB=8F=84=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=B5=EB=B6=80/=ED=9C=B4=EC=8B=9D=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=84=A4=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EC=B9=9C=EA=B5=AC=EB=93=A4=EA=B3=BC=20=EA=B3=B5=EC=9C=A0?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=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 내가 쓰는 공부/휴식 시간 조합에 이름을 붙여 공유하고, 다른 학생이 올린 루틴을 목록에서 골라 그대로 적용할 수 있다. 인기순/최신순 정렬, 많이 쓰인 루틴일수록 사용 횟수가 올라가서 눈에 띄게 했다. Co-Authored-By: Claude Sonnet 5 --- lib/function/study_preset_controller.dart | 125 +++++++++ lib/ui/study_preset_screen.dart | 324 ++++++++++++++++++++++ lib/ui/study_timer_screen.dart | 32 +++ 3 files changed, 481 insertions(+) create mode 100644 lib/function/study_preset_controller.dart create mode 100644 lib/ui/study_preset_screen.dart diff --git a/lib/function/study_preset_controller.dart b/lib/function/study_preset_controller.dart new file mode 100644 index 0000000..65ac662 --- /dev/null +++ b/lib/function/study_preset_controller.dart @@ -0,0 +1,125 @@ +// 🍅 뽀모도로 루틴 공유(다른 학생과 공부/휴식 시간 조합 공유) 화면의 기능(서버 통신/상태) 담당 컨트롤러. +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../config.dart'; + +class StudyPresetController extends ChangeNotifier { + final String studentId; + final String studentName; + + StudyPresetController({required this.studentId, required this.studentName}); + + bool _isLoading = true; + bool _isBusy = false; + String _sort = 'popular'; // popular / recent + List _presets = []; + bool _disposed = false; + + bool get isLoading => _isLoading; + bool get isBusy => _isBusy; + String get sort => _sort; + List get presets => _presets; + + void _safeNotify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + Future init() => fetchPresets(); + + Future setSort(String sort) async { + if (_sort == sort) return; + _sort = sort; + await fetchPresets(); + } + + Future fetchPresets() async { + _isLoading = true; + _safeNotify(); + try { + final response = await http.get( + Uri.parse('$baseUrl/api/study/presets?sort=$_sort'), + ); + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + _presets = data['presets'] ?? []; + } + } catch (_) { + // 조용히 무시 - 마지막으로 받아온 목록을 유지한다. + } finally { + _isLoading = false; + _safeNotify(); + } + } + + Future<(bool success, String message)> share({ + required String title, + required int studyMinutes, + required int breakMinutes, + }) async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/study/presets'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({ + "studentId": studentId, + "studentName": studentName, + "title": title.trim(), + "studyMinutes": studyMinutes, + "breakMinutes": breakMinutes, + }), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + await fetchPresets(); + return (true, '${result['message'] ?? '루틴을 공유했습니다.'}'); + } + return (false, '${result['message'] ?? '루틴 공유 실패'}'); + } catch (e) { + return (false, '네트워크 에러: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } + + /// 다른 학생의 루틴을 내 설정으로 적용할 때 호출 - 인기도(사용 횟수)만 올려준다. + Future markUsed(int presetId) async { + try { + await http.post(Uri.parse('$baseUrl/api/study/presets/$presetId/use')); + } catch (_) { + // 인기도 집계 실패는 조용히 무시 - 적용 자체는 이미 끝난 뒤라 영향 없다. + } + } + + Future<(bool success, String message)> delete(int presetId) async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.delete( + Uri.parse('$baseUrl/api/study/presets/$presetId'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"studentId": studentId}), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + await fetchPresets(); + return (true, '${result['message'] ?? '삭제되었습니다.'}'); + } + return (false, '${result['message'] ?? '삭제 실패'}'); + } catch (e) { + return (false, '네트워크 에러: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } +} diff --git a/lib/ui/study_preset_screen.dart b/lib/ui/study_preset_screen.dart new file mode 100644 index 0000000..65dc100 --- /dev/null +++ b/lib/ui/study_preset_screen.dart @@ -0,0 +1,324 @@ +// 🍅 뽀모도로 루틴 공유 화면 (UI 전용). 다른 학생들이 올린 공부/휴식 시간 조합을 보고 +// 그대로 적용하거나, 내가 쓰는 조합을 이름 붙여 공유할 수 있다. +// 서버 통신/상태는 lib/function/study_preset_controller.dart가 담당한다. +import 'package:flutter/material.dart'; +import '../function/study_preset_controller.dart'; +import '../theme/app_palette.dart'; +import 'app_notice.dart'; +import 'title_pill.dart'; + +class StudyPresetScreen extends StatefulWidget { + final String studentId; + final String studentName; + final int initialStudyMinutes; + final int initialBreakMinutes; + + const StudyPresetScreen({ + super.key, + required this.studentId, + required this.studentName, + required this.initialStudyMinutes, + required this.initialBreakMinutes, + }); + + @override + State createState() => _StudyPresetScreenState(); +} + +class _StudyPresetScreenState extends State { + late final StudyPresetController _controller; + + @override + void initState() { + super.initState(); + _controller = StudyPresetController( + studentId: widget.studentId, + studentName: widget.studentName, + ); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _apply(dynamic preset) async { + await _controller.markUsed(preset['id'] as int); + if (!mounted) return; + Navigator.of(context).pop({ + 'studyMinutes': preset['studyMinutes'] as int, + 'breakMinutes': preset['breakMinutes'] as int, + }); + } + + Future _delete(int presetId) async { + final (_, message) = await _controller.delete(presetId); + if (!mounted) return; + AppNotice.show(context, message); + } + + Future _showShareDialog() async { + final titleController = TextEditingController(); + int studyMinutes = widget.initialStudyMinutes; + int breakMinutes = widget.initialBreakMinutes; + + await showModalBottomSheet( + 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: [ + const Text( + '내 루틴 공유하기', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 12), + TextField( + controller: titleController, + maxLength: 30, + decoration: InputDecoration( + hintText: '루틴 이름 (예: 불태우는 금요일)', + filled: true, + fillColor: AppPalette.mist, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + _minuteRow( + label: '공부 시간', + minutes: studyMinutes, + onChanged: (v) => setSheetState(() => studyMinutes = v), + ), + _minuteRow( + label: '휴식 시간', + minutes: breakMinutes, + onChanged: (v) => setSheetState(() => breakMinutes = v), + ), + const SizedBox(height: 12), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: () async { + final (success, message) = await _controller.share( + title: titleController.text, + studyMinutes: studyMinutes, + breakMinutes: breakMinutes, + ); + if (sheetContext.mounted) { + Navigator.of(sheetContext).pop(); + } + if (!mounted) return; + AppNotice.show( + context, + message, + icon: success + ? Icons.check_circle_rounded + : Icons.error_outline_rounded, + ); + }, + child: const Text('공유하기'), + ), + ], + ), + ); + }, + ); + }, + ); + } + + Widget _minuteRow({ + required String label, + required int minutes, + required ValueChanged onChanged, + }) { + return Row( + children: [ + Expanded( + child: Text( + label, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + IconButton( + icon: const Icon(Icons.remove_circle_outline_rounded), + onPressed: 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: minutes < 120 ? () => onChanged(minutes + 5) : null, + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final presets = _controller.presets; + return Scaffold( + backgroundColor: AppPalette.mist, + appBar: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: AppPalette.ink, + elevation: 0, + centerTitle: true, + title: const TitlePill('뽀모도로 루틴 공유'), + ), + floatingActionButton: FloatingActionButton.extended( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + onPressed: _showShareDialog, + icon: const Icon(Icons.ios_share_rounded), + label: const Text('내 루틴 공유'), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + children: [ + _sortChip('popular', '인기순'), + const SizedBox(width: 8), + _sortChip('recent', '최신순'), + ], + ), + ), + Expanded( + child: _controller.isLoading + ? const Center(child: CircularProgressIndicator()) + : presets.isEmpty + ? const Center( + child: Text( + '아직 공유된 루틴이 없어요.\n첫 루틴을 공유해보세요.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + itemCount: presets.length, + itemBuilder: (context, index) { + final preset = presets[index]; + final bool isMine = + preset['studentId'] == widget.studentId; + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppPalette.sage), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + preset['title'] ?? '', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + const SizedBox(height: 4), + Text( + '공부 ${preset['studyMinutes']}분 · 휴식 ${preset['breakMinutes']}분', + style: const TextStyle( + color: AppPalette.ink, + fontSize: 13, + ), + ), + const SizedBox(height: 4), + Text( + '${preset['studentName'] ?? ''} · 사용 ${preset['useCount'] ?? 0}회', + style: const TextStyle( + color: Colors.grey, + fontSize: 11.5, + ), + ), + ], + ), + ), + if (isMine) + IconButton( + icon: const Icon( + Icons.delete_outline_rounded, + color: Colors.grey, + ), + onPressed: () => + _delete(preset['id'] as int), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + ), + onPressed: () => _apply(preset), + child: const Text('적용'), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _sortChip(String value, String label) { + final bool selected = _controller.sort == value; + return ChoiceChip( + label: Text(label), + selected: selected, + onSelected: (_) => _controller.setSort(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 index 840febd..0a83c0f 100644 --- a/lib/ui/study_timer_screen.dart +++ b/lib/ui/study_timer_screen.dart @@ -5,6 +5,7 @@ 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'; @@ -168,6 +169,37 @@ class _StudyTimerScreenState extends State { 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>( + 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(