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(