From 8a9b41453508a850a3264a7275b05a4c5f4846e3 Mon Sep 17 00:00:00 2001 From: sihoo Date: Mon, 21 Sep 2026 04:09:35 +0000 Subject: [PATCH] =?UTF-8?q?=EB=B0=B1=ED=92=88=ED=83=80=EC=97=90=20?= =?UTF-8?q?=EC=B9=9C=EA=B5=AC/=EA=B7=B8=EB=A3=B9=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94/=EC=B6=9C=EC=84=9D=20=EB=8B=AC=EB=A0=A5=20=EA=B8=B0?= =?UTF-8?q?=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 친구 신청·수락, 그룹 생성·초대(친구 목록에서 선택)·오늘 출석 현황 공유, 개인 연속 출석(스트릭) 달력, 관리자가 날짜별로 선물/이벤트를 등록하는 달력 관리 화면을 추가. 대시보드에 학생용 3개(친구/그룹 스터디/출석 달력) + 교사·관리자용 1개(출석 달력 관리) 타일을 배치. Co-Authored-By: Claude Sonnet 5 --- lib/function/friend_controller.dart | 152 ++++++++ lib/function/group_controller.dart | 124 ++++++ lib/function/group_detail_controller.dart | 103 +++++ lib/function/study_calendar_controller.dart | 150 ++++++++ lib/ui/friends_screen.dart | 289 ++++++++++++++ lib/ui/group_detail_screen.dart | 316 +++++++++++++++ lib/ui/group_list_screen.dart | 304 +++++++++++++++ lib/ui/main_dashboard.dart | 62 +++ lib/ui/study_calendar_screen.dart | 404 ++++++++++++++++++++ 9 files changed, 1904 insertions(+) create mode 100644 lib/function/friend_controller.dart create mode 100644 lib/function/group_controller.dart create mode 100644 lib/function/group_detail_controller.dart create mode 100644 lib/function/study_calendar_controller.dart create mode 100644 lib/ui/friends_screen.dart create mode 100644 lib/ui/group_detail_screen.dart create mode 100644 lib/ui/group_list_screen.dart create mode 100644 lib/ui/study_calendar_screen.dart 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])), + ], + ); + } +}