diff --git a/lib/function/friend_controller.dart b/lib/function/friend_controller.dart new file mode 100644 index 0000000..6c24ba3 --- /dev/null +++ b/lib/function/friend_controller.dart @@ -0,0 +1,152 @@ +// ๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ ๋ฐฑํ’ˆํƒ€ ์นœ๊ตฌ ๋ชฉ๋ก/์‹ ์ฒญ ํ™”๋ฉด์˜ ๊ธฐ๋Šฅ(์„œ๋ฒ„ ํ†ต์‹ /์ƒํƒœ) ๋‹ด๋‹น ์ปจํŠธ๋กค๋Ÿฌ. +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../config.dart'; + +class FriendController extends ChangeNotifier { + final String studentId; + final String studentName; + + FriendController({required this.studentId, required this.studentName}); + + bool _isLoading = true; + bool _isBusy = false; + List _friends = []; + List _requests = []; + bool _disposed = false; + + bool get isLoading => _isLoading; + bool get isBusy => _isBusy; + List get friends => _friends; + List get requests => _requests; + + void _safeNotify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + Future init() async { + await refresh(); + } + + Future refresh() async { + _isLoading = true; + _safeNotify(); + await Future.wait([_fetchFriends(), _fetchRequests()]); + _isLoading = false; + _safeNotify(); + } + + Future _fetchFriends() async { + try { + final response = await http.get( + Uri.parse('$baseUrl/api/friends/list?studentId=$studentId'), + ); + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + _friends = data['friends'] ?? []; + } + } catch (_) { + // ์กฐ์šฉํžˆ ๋ฌด์‹œ + } + } + + Future _fetchRequests() async { + try { + final response = await http.get( + Uri.parse('$baseUrl/api/friends/requests?studentId=$studentId'), + ); + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + _requests = data['requests'] ?? []; + } + } catch (_) { + // ์กฐ์šฉํžˆ ๋ฌด์‹œ + } + } + + Future<(bool success, String message)> sendRequest(String friendId) async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/friends/request'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({ + "studentId": studentId, + "studentName": studentName, + "friendId": friendId.trim(), + }), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + return (true, '${result['message'] ?? '์นœ๊ตฌ ์‹ ์ฒญ์„ ๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค.'}'); + } + return (false, '${result['message'] ?? '์นœ๊ตฌ ์‹ ์ฒญ ์‹คํŒจ'}'); + } catch (e) { + return (false, '๋„คํŠธ์›Œํฌ ์—๋Ÿฌ: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } + + Future<(bool success, String message)> respondRequest( + int requestId, + bool accept, + ) async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/friends/respond'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({ + "requestId": requestId, + "responderId": studentId, + "accept": accept, + }), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + await refresh(); + return (true, '${result['message'] ?? '์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.'}'); + } + return (false, '${result['message'] ?? '์ฒ˜๋ฆฌ ์‹คํŒจ'}'); + } catch (e) { + return (false, '๋„คํŠธ์›Œํฌ ์—๋Ÿฌ: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } + + Future<(bool success, String message)> removeFriend(String friendId) async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.delete( + Uri.parse('$baseUrl/api/friends'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"studentId": studentId, "friendId": friendId}), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + await _fetchFriends(); + return (true, '${result['message'] ?? '์‚ญ์ œํ–ˆ์Šต๋‹ˆ๋‹ค.'}'); + } + return (false, '${result['message'] ?? '์‚ญ์ œ ์‹คํŒจ'}'); + } catch (e) { + return (false, '๋„คํŠธ์›Œํฌ ์—๋Ÿฌ: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } +} diff --git a/lib/function/group_controller.dart b/lib/function/group_controller.dart new file mode 100644 index 0000000..95146c3 --- /dev/null +++ b/lib/function/group_controller.dart @@ -0,0 +1,124 @@ +// ๐Ÿ‘ฅ ๋ฐฑํ’ˆํƒ€ ๊ทธ๋ฃน ์Šคํ„ฐ๋””(๋ชฉ๋ก/์ƒ์„ฑ/์ดˆ๋Œ€) ํ™”๋ฉด์˜ ๊ธฐ๋Šฅ(์„œ๋ฒ„ ํ†ต์‹ /์ƒํƒœ) ๋‹ด๋‹น ์ปจํŠธ๋กค๋Ÿฌ. +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../config.dart'; + +class GroupController extends ChangeNotifier { + final String studentId; + final String studentName; + + GroupController({required this.studentId, required this.studentName}); + + bool _isLoading = true; + bool _isBusy = false; + List _groups = []; + List _invites = []; + bool _disposed = false; + + bool get isLoading => _isLoading; + bool get isBusy => _isBusy; + List get groups => _groups; + List get invites => _invites; + + void _safeNotify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + Future init() => refresh(); + + Future refresh() async { + _isLoading = true; + _safeNotify(); + await Future.wait([_fetchGroups(), _fetchInvites()]); + _isLoading = false; + _safeNotify(); + } + + Future _fetchGroups() async { + try { + final response = await http.get( + Uri.parse('$baseUrl/api/groups/mine?studentId=$studentId'), + ); + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + _groups = data['groups'] ?? []; + } + } catch (_) { + // ์กฐ์šฉํžˆ ๋ฌด์‹œ + } + } + + Future _fetchInvites() async { + try { + final response = await http.get( + Uri.parse('$baseUrl/api/groups/invites?studentId=$studentId'), + ); + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + _invites = data['invites'] ?? []; + } + } catch (_) { + // ์กฐ์šฉํžˆ ๋ฌด์‹œ + } + } + + Future<(bool success, String message)> createGroup(String name) async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/groups'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({ + "ownerId": studentId, + "ownerName": studentName, + "name": name.trim(), + }), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + await _fetchGroups(); + return (true, '${result['message'] ?? '๊ทธ๋ฃน์„ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค.'}'); + } + return (false, '${result['message'] ?? '๊ทธ๋ฃน ์ƒ์„ฑ ์‹คํŒจ'}'); + } catch (e) { + return (false, '๋„คํŠธ์›Œํฌ ์—๋Ÿฌ: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } + + Future<(bool success, String message)> respondInvite( + int inviteId, + bool accept, + ) async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/groups/invites/$inviteId/respond'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"responderId": studentId, "accept": accept}), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + await refresh(); + return (true, '${result['message'] ?? '์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.'}'); + } + return (false, '${result['message'] ?? '์ฒ˜๋ฆฌ ์‹คํŒจ'}'); + } catch (e) { + return (false, '๋„คํŠธ์›Œํฌ ์—๋Ÿฌ: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } +} diff --git a/lib/function/group_detail_controller.dart b/lib/function/group_detail_controller.dart new file mode 100644 index 0000000..fe7ff78 --- /dev/null +++ b/lib/function/group_detail_controller.dart @@ -0,0 +1,103 @@ +// ๐Ÿ‘ฅ ๋ฐฑํ’ˆํƒ€ ๊ทธ๋ฃน ์ƒ์„ธ(๋ฉค๋ฒ„/์˜ค๋Š˜ ์ถœ์„/์ดˆ๋Œ€/๋‚˜๊ฐ€๊ธฐ) ํ™”๋ฉด์˜ ๊ธฐ๋Šฅ ๋‹ด๋‹น ์ปจํŠธ๋กค๋Ÿฌ. +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../config.dart'; + +class GroupDetailController extends ChangeNotifier { + final int groupId; + final String studentId; + + GroupDetailController({required this.groupId, required this.studentId}); + + bool _isLoading = true; + bool _isBusy = false; + List _members = []; + bool _disposed = false; + + bool get isLoading => _isLoading; + bool get isBusy => _isBusy; + List get members => _members; + + void _safeNotify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + Future init() => refresh(); + + Future refresh() async { + _isLoading = true; + _safeNotify(); + try { + final response = await http.get( + Uri.parse('$baseUrl/api/groups/$groupId/members'), + ); + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + _members = data['members'] ?? []; + } + } catch (_) { + // ์กฐ์šฉํžˆ ๋ฌด์‹œ + } finally { + _isLoading = false; + _safeNotify(); + } + } + + Future<(bool success, String message)> invite( + String inviteeId, + String inviteeName, + ) async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/groups/$groupId/invite'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({ + "inviterId": studentId, + "inviteeId": inviteeId, + "inviteeName": inviteeName, + }), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + return (true, '${result['message'] ?? '์ดˆ๋Œ€๋ฅผ ๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค.'}'); + } + return (false, '${result['message'] ?? '์ดˆ๋Œ€ ์‹คํŒจ'}'); + } catch (e) { + return (false, '๋„คํŠธ์›Œํฌ ์—๋Ÿฌ: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } + + Future<(bool success, String message)> leave() async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/groups/$groupId/leave'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"studentId": studentId}), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + return (true, '${result['message'] ?? '๊ทธ๋ฃน์—์„œ ๋‚˜๊ฐ”์Šต๋‹ˆ๋‹ค.'}'); + } + return (false, '${result['message'] ?? '์ฒ˜๋ฆฌ ์‹คํŒจ'}'); + } catch (e) { + return (false, '๋„คํŠธ์›Œํฌ ์—๋Ÿฌ: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } +} diff --git a/lib/function/study_calendar_controller.dart b/lib/function/study_calendar_controller.dart new file mode 100644 index 0000000..7293256 --- /dev/null +++ b/lib/function/study_calendar_controller.dart @@ -0,0 +1,150 @@ +// ๐Ÿ“… ๋ฐฑํ’ˆํƒ€ ์ถœ์„ ๋‹ฌ๋ ฅ(๊ฐœ์ธ ์ŠคํŠธ๋ฆญ + ๊ด€๋ฆฌ์ž ์ด๋ฒคํŠธ) ํ™”๋ฉด์˜ ๊ธฐ๋Šฅ ๋‹ด๋‹น ์ปจํŠธ๋กค๋Ÿฌ. +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../config.dart'; + +class StudyCalendarController extends ChangeNotifier { + final String studentId; + + StudyCalendarController({required this.studentId}); + + bool _isLoading = true; + bool _isBusy = false; + int _currentStreak = 0; + int _longestStreak = 0; + Set _studiedDates = {}; + Map _events = {}; // date -> {title, description} + DateTime _visibleMonth = DateTime.now(); + bool _disposed = false; + + bool get isLoading => _isLoading; + bool get isBusy => _isBusy; + int get currentStreak => _currentStreak; + int get longestStreak => _longestStreak; + Set get studiedDates => _studiedDates; + Map get events => _events; + DateTime get visibleMonth => _visibleMonth; + + void _safeNotify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + Future init() => refresh(); + + Future goToPreviousMonth() async { + _visibleMonth = DateTime(_visibleMonth.year, _visibleMonth.month - 1); + await refresh(); + } + + Future goToNextMonth() async { + _visibleMonth = DateTime(_visibleMonth.year, _visibleMonth.month + 1); + await refresh(); + } + + Future refresh() async { + _isLoading = true; + _safeNotify(); + await Future.wait([_fetchStreak(), _fetchEvents()]); + _isLoading = false; + _safeNotify(); + } + + Future _fetchStreak() async { + try { + final response = await http.get( + Uri.parse( + '$baseUrl/api/study/streak?studentId=$studentId' + '&year=${_visibleMonth.year}&month=${_visibleMonth.month}', + ), + ); + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + _currentStreak = (data['currentStreak'] as num?)?.toInt() ?? 0; + _longestStreak = (data['longestStreak'] as num?)?.toInt() ?? 0; + _studiedDates = ((data['studiedDates'] as List?) ?? []) + .map((d) => d.toString()) + .toSet(); + } + } catch (_) { + // ์กฐ์šฉํžˆ ๋ฌด์‹œ + } + } + + Future _fetchEvents() async { + try { + final response = await http.get( + Uri.parse( + '$baseUrl/api/calendar/events?year=${_visibleMonth.year}&month=${_visibleMonth.month}', + ), + ); + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + final List list = data['events'] ?? []; + _events = {for (final e in list) e['date']: e}; + } + } catch (_) { + // ์กฐ์šฉํžˆ ๋ฌด์‹œ + } + } + + Future<(bool success, String message)> saveEvent({ + required String date, + required String title, + required String description, + required String createdBy, + }) async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/calendar/events'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({ + "date": date, + "title": title.trim(), + "description": description.trim(), + "createdBy": createdBy, + }), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + await _fetchEvents(); + return (true, '${result['message'] ?? '๋“ฑ๋กํ–ˆ์Šต๋‹ˆ๋‹ค.'}'); + } + return (false, '${result['message'] ?? '๋“ฑ๋ก ์‹คํŒจ'}'); + } catch (e) { + return (false, '๋„คํŠธ์›Œํฌ ์—๋Ÿฌ: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } + + Future<(bool success, String message)> deleteEvent(String date) async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.delete( + Uri.parse('$baseUrl/api/calendar/events/$date'), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + await _fetchEvents(); + return (true, '${result['message'] ?? '์‚ญ์ œํ–ˆ์Šต๋‹ˆ๋‹ค.'}'); + } + return (false, '${result['message'] ?? '์‚ญ์ œ ์‹คํŒจ'}'); + } catch (e) { + return (false, '๋„คํŠธ์›Œํฌ ์—๋Ÿฌ: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } +} diff --git a/lib/ui/friends_screen.dart b/lib/ui/friends_screen.dart new file mode 100644 index 0000000..2560e5d --- /dev/null +++ b/lib/ui/friends_screen.dart @@ -0,0 +1,289 @@ +// ๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ ๋ฐฑํ’ˆํƒ€ ์นœ๊ตฌ ํ™”๋ฉด (UI ์ „์šฉ). "์‹ค์‹œ๊ฐ„ ์ถœ์„ ํ˜„ํ™ฉ"๊ณผ ๊ฐ™์€ ํŒจ๋ฐ€๋ฆฌ๋ฃฉ์„ ๋”ฐ๋ฅธ๋‹ค. +// ์„œ๋ฒ„ ํ†ต์‹ /์ƒํƒœ๋Š” lib/function/friend_controller.dart๊ฐ€ ๋‹ด๋‹นํ•œ๋‹ค. +import 'package:flutter/material.dart'; +import '../function/friend_controller.dart'; +import '../theme/app_palette.dart'; +import 'app_notice.dart'; +import 'title_pill.dart'; + +class FriendsScreen extends StatefulWidget { + final String studentId; + final String studentName; + + const FriendsScreen({ + super.key, + required this.studentId, + required this.studentName, + }); + + @override + State createState() => _FriendsScreenState(); +} + +class _FriendsScreenState extends State { + late final FriendController _controller; + + @override + void initState() { + super.initState(); + _controller = FriendController( + studentId: widget.studentId, + studentName: widget.studentName, + ); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _showAddFriendDialog() async { + final idController = TextEditingController(); + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('์นœ๊ตฌ ์ถ”๊ฐ€'), + content: TextField( + controller: idController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '์นœ๊ตฌ ํ•™๋ฒˆ', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('์ทจ์†Œ', style: TextStyle(color: Colors.grey)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + ), + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('์‹ ์ฒญํ•˜๊ธฐ'), + ), + ], + ), + ); + if (result != true || idController.text.trim().isEmpty) return; + final (success, message) = await _controller.sendRequest( + idController.text.trim(), + ); + if (!mounted) return; + AppNotice.show( + context, + message, + icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded, + ); + } + + Future _respond(int requestId, bool accept) async { + final (_, message) = await _controller.respondRequest(requestId, accept); + if (!mounted) return; + AppNotice.show(context, message); + } + + Future _remove(String friendId) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('์นœ๊ตฌ ์‚ญ์ œ'), + content: const Text('์ด ์นœ๊ตฌ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('์ทจ์†Œ', style: TextStyle(color: Colors.grey)), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('์‚ญ์ œ', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + if (confirmed != true) return; + final (_, message) = await _controller.removeFriend(friendId); + if (!mounted) return; + AppNotice.show(context, message); + } + + Widget _sectionHeader(String title, {int? count}) { + return Row( + children: [ + Container( + width: 4, + height: 16, + decoration: BoxDecoration( + color: AppPalette.ink, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppPalette.ink, + ), + ), + if (count != null) ...[ + const SizedBox(width: 6), + Text( + '$count๋ช…', + style: TextStyle(fontSize: 13, color: Colors.grey[500]), + ), + ], + ], + ); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + return Scaffold( + backgroundColor: AppPalette.mist, + appBar: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: AppPalette.ink, + elevation: 0, + ), + floatingActionButton: FloatingActionButton.extended( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + onPressed: _showAddFriendDialog, + icon: const Icon(Icons.person_add_rounded), + label: const Text('์นœ๊ตฌ ์ถ”๊ฐ€'), + ), + body: _controller.isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _controller.refresh, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 90), + children: [ + const Center(child: TitlePill('์นœ๊ตฌ')), + const SizedBox(height: 20), + if (_controller.requests.isNotEmpty) ...[ + _sectionHeader( + '๋ฐ›์€ ์นœ๊ตฌ ์‹ ์ฒญ', + count: _controller.requests.length, + ), + const SizedBox(height: 10), + for (final r in _controller.requests) + Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppPalette.sage), + ), + child: Row( + children: [ + Expanded( + child: Text( + '${r['name']} (${r['studentId']})', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), + TextButton( + onPressed: () => + _respond(r['requestId'] as int, false), + child: const Text( + '๊ฑฐ์ ˆ', + style: TextStyle(color: Colors.grey), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + ), + onPressed: () => + _respond(r['requestId'] as int, true), + child: const Text('์ˆ˜๋ฝ'), + ), + ], + ), + ), + const SizedBox(height: 20), + ], + _sectionHeader('๋‚ด ์นœ๊ตฌ', count: _controller.friends.length), + const SizedBox(height: 10), + if (_controller.friends.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text( + '์•„์ง ์นœ๊ตฌ๊ฐ€ ์—†์–ด์š”. ํ•™๋ฒˆ์œผ๋กœ ์นœ๊ตฌ๋ฅผ ์ถ”๊ฐ€ํ•ด๋ณด์„ธ์š”.', + style: TextStyle(color: Colors.grey), + ), + ) + else + for (final f in _controller.friends) + Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppPalette.sage), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppPalette.ink.withValues( + alpha: 0.06, + ), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.person_rounded, + size: 16, + color: AppPalette.ink, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '${f['name']} (${f['studentId']})', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon( + Icons.person_remove_outlined, + color: Colors.grey, + ), + onPressed: () => + _remove(f['studentId'] as String), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/group_detail_screen.dart b/lib/ui/group_detail_screen.dart new file mode 100644 index 0000000..803573d --- /dev/null +++ b/lib/ui/group_detail_screen.dart @@ -0,0 +1,316 @@ +// ๐Ÿ‘ฅ ๋ฐฑํ’ˆํƒ€ ๊ทธ๋ฃน ์ƒ์„ธ ํ™”๋ฉด (UI ์ „์šฉ) - ๋ฉค๋ฒ„ ์˜ค๋Š˜ ์ถœ์„ ํ˜„ํ™ฉ, ์นœ๊ตฌ ์ดˆ๋Œ€, ๊ทธ๋ฃน ๋‚˜๊ฐ€๊ธฐ. +// ์„œ๋ฒ„ ํ†ต์‹ /์ƒํƒœ๋Š” lib/function/group_detail_controller.dart, friend_controller.dart๊ฐ€ ๋‹ด๋‹นํ•œ๋‹ค. +import 'package:flutter/material.dart'; +import '../function/friend_controller.dart'; +import '../function/group_detail_controller.dart'; +import '../theme/app_palette.dart'; +import 'app_notice.dart'; +import 'title_pill.dart'; + +class GroupDetailScreen extends StatefulWidget { + final int groupId; + final String groupName; + final String studentId; + + const GroupDetailScreen({ + super.key, + required this.groupId, + required this.groupName, + required this.studentId, + }); + + @override + State createState() => _GroupDetailScreenState(); +} + +class _GroupDetailScreenState extends State { + late final GroupDetailController _controller; + + @override + void initState() { + super.initState(); + _controller = GroupDetailController( + groupId: widget.groupId, + studentId: widget.studentId, + ); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _showInviteSheet() async { + final friendController = FriendController( + studentId: widget.studentId, + studentName: '', + ); + await friendController.init(); + if (!mounted) return; + + final memberIds = _controller.members + .map((m) => m['studentId'] as String) + .toSet(); + final invitable = friendController.friends + .where((f) => !memberIds.contains(f['studentId'])) + .toList(); + + await showModalBottomSheet( + context: context, + backgroundColor: AppPalette.paper, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + '์นœ๊ตฌ ์ดˆ๋Œ€', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 12), + if (invitable.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Text( + '์ดˆ๋Œ€ํ•  ์ˆ˜ ์žˆ๋Š” ์นœ๊ตฌ๊ฐ€ ์—†์–ด์š”.\n(์ด๋ฏธ ๊ทธ๋ฃน์— ์žˆ๊ฑฐ๋‚˜ ์นœ๊ตฌ๊ฐ€ ์—†์–ด์š”)', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: ListView( + shrinkWrap: true, + children: [ + for (final f in invitable) + ListTile( + leading: const Icon( + Icons.person_rounded, + color: AppPalette.ink, + ), + title: Text('${f['name']}'), + subtitle: Text('${f['studentId']}'), + trailing: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + ), + onPressed: () async { + final (success, message) = await _controller + .invite( + f['studentId'] as String, + f['name'] as String, + ); + 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('์ดˆ๋Œ€'), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + friendController.dispose(); + } + + Future _leave() async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('๊ทธ๋ฃน ๋‚˜๊ฐ€๊ธฐ'), + content: Text('${widget.groupName} ๊ทธ๋ฃน์—์„œ ๋‚˜๊ฐ€์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('์ทจ์†Œ', style: TextStyle(color: Colors.grey)), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('๋‚˜๊ฐ€๊ธฐ', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + if (confirmed != true) return; + final (success, message) = await _controller.leave(); + if (!mounted) return; + AppNotice.show(context, message); + if (success) Navigator.of(context).pop(); + } + + String _formatMinutes(int seconds) { + final m = seconds ~/ 60; + if (m < 60) return '$m๋ถ„'; + return '${m ~/ 60}์‹œ๊ฐ„ ${m % 60}๋ถ„'; + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final members = _controller.members; + final attendedCount = members + .where((m) => m['attendedToday'] == true) + .length; + + return Scaffold( + backgroundColor: AppPalette.mist, + appBar: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: AppPalette.ink, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.exit_to_app_rounded), + tooltip: '๊ทธ๋ฃน ๋‚˜๊ฐ€๊ธฐ', + onPressed: _leave, + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + onPressed: _showInviteSheet, + icon: const Icon(Icons.person_add_rounded), + label: const Text('์นœ๊ตฌ ์ดˆ๋Œ€'), + ), + body: _controller.isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _controller.refresh, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 90), + children: [ + Center(child: TitlePill(widget.groupName)), + const SizedBox(height: 16), + Row( + children: [ + Expanded(child: _statPill('์ „์ฒด ๋ฉค๋ฒ„', members.length)), + const SizedBox(width: 8), + Expanded(child: _statPill('์˜ค๋Š˜ ๊ณต๋ถ€ํ•จ', attendedCount)), + ], + ), + const SizedBox(height: 20), + Row( + children: [ + Container( + width: 4, + height: 16, + decoration: BoxDecoration( + color: AppPalette.ink, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + const Text( + '์˜ค๋Š˜ ์ถœ์„ ํ˜„ํ™ฉ', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppPalette.ink, + ), + ), + ], + ), + const SizedBox(height: 10), + for (final m in members) + Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppPalette.sage), + ), + child: Row( + children: [ + Icon( + m['attendedToday'] == true + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked_rounded, + color: m['attendedToday'] == true + ? AppPalette.ink + : Colors.grey[350], + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '${m['name']}' + '${m['role'] == 'owner' ? ' (๊ทธ๋ฃน์žฅ)' : ''}', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + _formatMinutes( + (m['todaySeconds'] as num?)?.toInt() ?? 0, + ), + style: TextStyle( + color: Colors.grey[500], + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _statPill(String label, int count) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + decoration: BoxDecoration( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: AppPalette.sage), + ), + child: Column( + children: [ + Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])), + const SizedBox(height: 4), + Text( + '$count๋ช…', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppPalette.ink, + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/group_list_screen.dart b/lib/ui/group_list_screen.dart new file mode 100644 index 0000000..0f46923 --- /dev/null +++ b/lib/ui/group_list_screen.dart @@ -0,0 +1,304 @@ +// ๐Ÿ‘ฅ ๋ฐฑํ’ˆํƒ€ ๊ทธ๋ฃน ์Šคํ„ฐ๋”” ๋ชฉ๋ก ํ™”๋ฉด (UI ์ „์šฉ). "์‹ค์‹œ๊ฐ„ ์ถœ์„ ํ˜„ํ™ฉ"๊ณผ ๊ฐ™์€ ํŒจ๋ฐ€๋ฆฌ๋ฃฉ์„ ๋”ฐ๋ฅธ๋‹ค. +// ์„œ๋ฒ„ ํ†ต์‹ /์ƒํƒœ๋Š” lib/function/group_controller.dart๊ฐ€ ๋‹ด๋‹นํ•œ๋‹ค. +import 'package:flutter/material.dart'; +import '../function/group_controller.dart'; +import '../theme/app_palette.dart'; +import 'app_notice.dart'; +import 'group_detail_screen.dart'; +import 'launchpad_transition.dart'; +import 'title_pill.dart'; + +class GroupListScreen extends StatefulWidget { + final String studentId; + final String studentName; + + const GroupListScreen({ + super.key, + required this.studentId, + required this.studentName, + }); + + @override + State createState() => _GroupListScreenState(); +} + +class _GroupListScreenState extends State { + late final GroupController _controller; + + @override + void initState() { + super.initState(); + _controller = GroupController( + studentId: widget.studentId, + studentName: widget.studentName, + ); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _showCreateGroupDialog() async { + final nameController = TextEditingController(); + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('๊ทธ๋ฃน ๋งŒ๋“ค๊ธฐ'), + content: TextField( + controller: nameController, + maxLength: 20, + decoration: const InputDecoration( + labelText: '๊ทธ๋ฃน ์ด๋ฆ„', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('์ทจ์†Œ', style: TextStyle(color: Colors.grey)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + ), + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('๋งŒ๋“ค๊ธฐ'), + ), + ], + ), + ); + if (result != true || nameController.text.trim().isEmpty) return; + final (success, message) = await _controller.createGroup( + nameController.text.trim(), + ); + if (!mounted) return; + AppNotice.show( + context, + message, + icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded, + ); + } + + Future _respondInvite(int inviteId, bool accept) async { + final (_, message) = await _controller.respondInvite(inviteId, accept); + if (!mounted) return; + AppNotice.show(context, message); + } + + Future _openGroup(dynamic group) async { + await pushLaunchpad( + context, + (context) => GroupDetailScreen( + groupId: group['groupId'] as int, + groupName: group['name'] as String, + studentId: widget.studentId, + ), + ); + _controller.refresh(); + } + + Widget _sectionHeader(String title, {int? count}) { + return Row( + children: [ + Container( + width: 4, + height: 16, + decoration: BoxDecoration( + color: AppPalette.ink, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppPalette.ink, + ), + ), + if (count != null) ...[ + const SizedBox(width: 6), + Text( + '$count๊ฐœ', + style: TextStyle(fontSize: 13, color: Colors.grey[500]), + ), + ], + ], + ); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + return Scaffold( + backgroundColor: AppPalette.mist, + appBar: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: AppPalette.ink, + elevation: 0, + ), + floatingActionButton: FloatingActionButton.extended( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + onPressed: _showCreateGroupDialog, + icon: const Icon(Icons.add_rounded), + label: const Text('๊ทธ๋ฃน ๋งŒ๋“ค๊ธฐ'), + ), + body: _controller.isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _controller.refresh, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 90), + children: [ + const Center(child: TitlePill('๊ทธ๋ฃน ์Šคํ„ฐ๋””')), + const SizedBox(height: 20), + if (_controller.invites.isNotEmpty) ...[ + _sectionHeader( + '๋ฐ›์€ ๊ทธ๋ฃน ์ดˆ๋Œ€', + count: _controller.invites.length, + ), + const SizedBox(height: 10), + for (final inv in _controller.invites) + Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppPalette.sage), + ), + child: Row( + children: [ + Expanded( + child: Text( + '${inv['groupName']}', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), + TextButton( + onPressed: () => _respondInvite( + inv['inviteId'] as int, + false, + ), + child: const Text( + '๊ฑฐ์ ˆ', + style: TextStyle(color: Colors.grey), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + ), + onPressed: () => _respondInvite( + inv['inviteId'] as int, + true, + ), + child: const Text('์ฐธ์—ฌ'), + ), + ], + ), + ), + const SizedBox(height: 20), + ], + _sectionHeader('๋‚ด ๊ทธ๋ฃน', count: _controller.groups.length), + const SizedBox(height: 10), + if (_controller.groups.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text( + '์•„์ง ๊ทธ๋ฃน์ด ์—†์–ด์š”. ์ƒˆ ๊ทธ๋ฃน์„ ๋งŒ๋“ค๊ฑฐ๋‚˜ ์ดˆ๋Œ€๋ฅผ ๊ธฐ๋‹ค๋ ค๋ณด์„ธ์š”.', + style: TextStyle(color: Colors.grey), + ), + ) + else + for (final g in _controller.groups) + GestureDetector( + onTap: () => _openGroup(g), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + decoration: BoxDecoration( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppPalette.sage), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppPalette.ink.withValues( + alpha: 0.06, + ), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.groups_rounded, + color: AppPalette.ink, + size: 18, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + '${g['name']}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + Text( + '๋ฉค๋ฒ„ ${g['memberCount']}๋ช…' + '${g['isOwner'] == true ? ' ยท ๊ทธ๋ฃน์žฅ' : ''}', + style: TextStyle( + fontSize: 12, + color: Colors.grey[500], + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: Colors.grey, + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/main_dashboard.dart b/lib/ui/main_dashboard.dart index 439ba18..1c86dc4 100644 --- a/lib/ui/main_dashboard.dart +++ b/lib/ui/main_dashboard.dart @@ -17,10 +17,13 @@ import 'board_report_screen.dart'; import 'dashboard_settings_page.dart'; import 'device_checkout_ledger_page.dart'; import 'device_checkout_request_screen.dart'; +import 'friends_screen.dart'; +import 'group_list_screen.dart'; import 'launchpad_transition.dart'; import 'login_screen.dart'; import 'nfc_poccket_checkin_screen.dart'; import 'nfc_tag_writer_screen.dart'; +import 'study_calendar_screen.dart'; import 'study_timer_screen.dart'; import 'teacher_attendance_page.dart'; import 'teacher_call_screen.dart'; @@ -365,6 +368,49 @@ class _MainDashboardState extends State { ), ), )); + tiles.add(( + id: 'friends', + child: _buildModernCard( + icon: Icons.people_alt_rounded, + title: '์นœ๊ตฌ', + subtitle: 'ํ•™๋ฒˆ์œผ๋กœ ์นœ๊ตฌ ์ถ”๊ฐ€', + color: AppPalette.ink, + onTap: () => pushLaunchpad( + context, + (context) => + FriendsScreen(studentId: _displayId, studentName: _displayName), + ), + ), + )); + tiles.add(( + id: 'group_study', + child: _buildModernCard( + icon: Icons.groups_rounded, + title: '๊ทธ๋ฃน ์Šคํ„ฐ๋””', + subtitle: '์นœ๊ตฌ๋ž‘ ๊ทธ๋ฃน ๋งŒ๋“ค์–ด ๊ฐ™์ด ๊ณต๋ถ€', + color: AppPalette.ink, + onTap: () => pushLaunchpad( + context, + (context) => GroupListScreen( + studentId: _displayId, + studentName: _displayName, + ), + ), + ), + )); + tiles.add(( + id: 'study_calendar', + child: _buildModernCard( + icon: Icons.calendar_month_rounded, + title: '์ถœ์„ ๋‹ฌ๋ ฅ', + subtitle: '์—ฐ์† ์ถœ์„ ๊ธฐ๋ก ํ™•์ธ', + color: AppPalette.ink, + onTap: () => pushLaunchpad( + context, + (context) => StudyCalendarScreen(studentId: _displayId), + ), + ), + )); } if (_isTeacherOrAbove) { @@ -423,6 +469,22 @@ class _MainDashboardState extends State { ), ), )); + tiles.add(( + id: 'study_calendar_admin', + child: _buildModernCard( + icon: Icons.event_available_rounded, + title: '์ถœ์„ ๋‹ฌ๋ ฅ ๊ด€๋ฆฌ', + subtitle: '๋‚ ์งœ๋ณ„ ์„ ๋ฌผ/์ด๋ฒคํŠธ ๋“ฑ๋ก', + color: AppPalette.ink, + onTap: () => pushLaunchpad( + context, + (context) => StudyCalendarScreen( + studentId: _displayId, + canManageEvents: true, + ), + ), + ), + )); tiles.add(( id: 'teacher_location', child: _buildModernCard( diff --git a/lib/ui/study_calendar_screen.dart b/lib/ui/study_calendar_screen.dart new file mode 100644 index 0000000..9b1404d --- /dev/null +++ b/lib/ui/study_calendar_screen.dart @@ -0,0 +1,404 @@ +// ๐Ÿ“… ๋ฐฑํ’ˆํƒ€ ์ถœ์„ ๋‹ฌ๋ ฅ ํ™”๋ฉด (UI ์ „์šฉ) - ๊ฐœ์ธ ์—ฐ์†์ถœ์„(์ŠคํŠธ๋ฆญ) + ๊ด€๋ฆฌ์ž ์ด๋ฒคํŠธ ๋‹ฌ๋ ฅ. +// "์‹ค์‹œ๊ฐ„ ์ถœ์„ ํ˜„ํ™ฉ"๊ณผ ๊ฐ™์€ ํŒจ๋ฐ€๋ฆฌ๋ฃฉ(์ œ๋ชฉ ์•Œ์•ฝ + ํ•„ ํ†ต๊ณ„ + ๋‘ฅ๊ทผ ์นด๋“œ)์„ ๋”ฐ๋ฅธ๋‹ค. +// ์„œ๋ฒ„ ํ†ต์‹ /์ƒํƒœ๋Š” lib/function/study_calendar_controller.dart๊ฐ€ ๋‹ด๋‹นํ•œ๋‹ค. +import 'package:flutter/material.dart'; +import '../function/study_calendar_controller.dart'; +import '../theme/app_palette.dart'; +import 'app_notice.dart'; +import 'title_pill.dart'; + +class StudyCalendarScreen extends StatefulWidget { + final String studentId; + final bool canManageEvents; + + const StudyCalendarScreen({ + super.key, + required this.studentId, + this.canManageEvents = false, + }); + + @override + State createState() => _StudyCalendarScreenState(); +} + +class _StudyCalendarScreenState extends State { + late final StudyCalendarController _controller; + static const _weekdayLabels = ['์ผ', '์›”', 'ํ™”', '์ˆ˜', '๋ชฉ', '๊ธˆ', 'ํ† ']; + + @override + void initState() { + super.initState(); + _controller = StudyCalendarController(studentId: widget.studentId); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + String _dateKey(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + + Future _onDayTap(DateTime day) async { + final key = _dateKey(day); + final event = _controller.events[key]; + + if (!widget.canManageEvents) { + if (event != null) { + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(event['title'] ?? ''), + content: Text( + (event['description'] ?? '').toString().isEmpty + ? '๋“ฑ๋ก๋œ ์„ค๋ช…์ด ์—†์–ด์š”.' + : event['description'], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('๋‹ซ๊ธฐ'), + ), + ], + ), + ); + } + return; + } + + final titleController = TextEditingController(text: event?['title'] ?? ''); + final descController = TextEditingController( + text: event?['description'] ?? '', + ); + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppPalette.paper, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + 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: [ + Text( + '$key ์ด๋ฒคํŠธ', + style: const 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, + ), + ), + ), + TextField( + controller: descController, + maxLength: 100, + maxLines: 3, + decoration: InputDecoration( + hintText: '์„ค๋ช… (์„ ํƒ)', + filled: true, + fillColor: AppPalette.mist, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + if (event != null) + Expanded( + child: OutlinedButton( + onPressed: () async { + final (success, message) = await _controller + .deleteEvent(key); + if (sheetContext.mounted) { + Navigator.of(sheetContext).pop(); + } + if (!mounted) return; + AppNotice.show(context, message); + }, + child: const Text( + '์‚ญ์ œ', + style: TextStyle(color: Colors.red), + ), + ), + ), + if (event != null) const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + ), + onPressed: () async { + if (titleController.text.trim().isEmpty) return; + final (success, message) = await _controller.saveEvent( + date: key, + title: titleController.text, + description: descController.text, + createdBy: widget.studentId, + ); + 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('์ €์žฅ'), + ), + ), + ], + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final month = _controller.visibleMonth; + final firstOfMonth = DateTime(month.year, month.month, 1); + final daysInMonth = DateTime(month.year, month.month + 1, 0).day; + final leadingBlanks = firstOfMonth.weekday % 7; + final today = DateTime.now(); + + return Scaffold( + backgroundColor: AppPalette.mist, + appBar: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: AppPalette.ink, + elevation: 0, + ), + body: _controller.isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + children: [ + const Center(child: TitlePill('์ถœ์„ ๋‹ฌ๋ ฅ')), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _statPill('์—ฐ์† ์ถœ์„', _controller.currentStreak), + ), + const SizedBox(width: 8), + Expanded( + child: _statPill('์ตœ์žฅ ๊ธฐ๋ก', _controller.longestStreak), + ), + ], + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left_rounded), + onPressed: _controller.goToPreviousMonth, + ), + Text( + '${month.year}๋…„ ${month.month}์›”', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right_rounded), + onPressed: _controller.goToNextMonth, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + for (final label in _weekdayLabels) + Expanded( + child: Center( + child: Text( + label, + style: TextStyle( + color: Colors.grey[500], + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 6), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 0.85, + ), + itemCount: leadingBlanks + daysInMonth, + itemBuilder: (context, index) { + if (index < leadingBlanks) { + return const SizedBox.shrink(); + } + final day = index - leadingBlanks + 1; + final date = DateTime(month.year, month.month, day); + final key = _dateKey(date); + final studied = _controller.studiedDates.contains(key); + final event = _controller.events[key]; + final isToday = + date.year == today.year && + date.month == today.month && + date.day == today.day; + + return GestureDetector( + onTap: () => _onDayTap(date), + child: Container( + decoration: BoxDecoration( + color: studied + ? AppPalette.ink + : AppPalette.paper, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isToday + ? AppPalette.ink + : AppPalette.sage, + width: isToday ? 2 : 1, + ), + ), + child: Stack( + children: [ + Center( + child: Text( + '$day', + style: TextStyle( + fontWeight: FontWeight.bold, + color: studied + ? Colors.white + : AppPalette.ink, + ), + ), + ), + if (event != null) + Positioned( + top: 3, + right: 3, + child: Icon( + Icons.card_giftcard_rounded, + size: 12, + color: studied + ? Colors.white + : Colors.orange, + ), + ), + ], + ), + ), + ); + }, + ), + const SizedBox(height: 16), + Row( + children: [ + _legendDot(AppPalette.ink, '๊ณต๋ถ€ํ•œ ๋‚ '), + const SizedBox(width: 16), + _legendIcon( + Icons.card_giftcard_rounded, + Colors.orange, + '์ด๋ฒคํŠธ', + ), + ], + ), + ], + ), + ); + }, + ); + } + + Widget _statPill(String label, int count) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + decoration: BoxDecoration( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: AppPalette.sage), + ), + child: Column( + children: [ + Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])), + const SizedBox(height: 4), + Text( + '$count์ผ', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppPalette.ink, + ), + ), + ], + ), + ); + } + + Widget _legendDot(Color color, String label) { + return Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])), + ], + ); + } + + Widget _legendIcon(IconData icon, Color color, String label) { + return Row( + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])), + ], + ); + } +}