Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12780eb3af | ||
|
|
f6be60ef46 | ||
|
|
726d2dc632 | ||
|
|
4e74667993 | ||
|
|
0fc80eb5c5 | ||
|
|
edf0444b71 | ||
|
|
6b53214913 | ||
|
|
13a86ec95e | ||
|
|
a29efe77ad | ||
|
|
a4a7b5be48 | ||
|
|
705c0c086a | ||
|
|
9fe3e61c1c | ||
|
|
153c341efc | ||
|
|
ae3a3f18ea | ||
|
|
0055b3d40b | ||
|
|
6b90ae3925 | ||
|
|
206eec03bc | ||
|
|
51a106faf4 | ||
|
|
521355626d | ||
|
|
6b66edcd40 | ||
|
|
3f156b8833 | ||
|
|
10051e9896 | ||
|
|
bebbcb1d54 | ||
|
|
37f67540dd | ||
|
|
b6e77ad1fa | ||
|
|
80667d20f1 | ||
|
|
f5aa832ad9 | ||
|
|
4f64315197 | ||
|
|
1140747af2 | ||
|
|
cb0984ed6f | ||
|
|
e8b375a18c | ||
|
|
8a9b414535 | ||
|
|
bdc79b94c3 | ||
|
|
86a8f7b9d1 | ||
|
|
4cf759de62 | ||
|
|
89487a7dfc | ||
|
|
d183370cca | ||
|
|
7277eb5962 | ||
|
|
094ce2292c | ||
|
|
93104362be | ||
|
|
73772de78a | ||
|
|
a59a3054fb | ||
|
|
26e54a1798 | ||
|
|
04892d5e65 | ||
|
|
a3894cc878 | ||
|
|
ce77c32026 | ||
|
|
a0403e40f2 | ||
|
|
7e83884d33 | ||
|
|
0467f31996 | ||
|
|
2d87a681ca | ||
|
|
c40cbed0bb | ||
|
|
5d37d3e3b1 | ||
|
|
527661b6e5 | ||
|
|
d9ca4913d4 | ||
|
|
3946e2ef04 | ||
|
|
f80f1f7837 | ||
|
|
32203baffc | ||
|
|
20f751b65e | ||
|
|
2d0768da73 |
+73
-1
@@ -1 +1,73 @@
|
||||
{"flutter":{"platforms":{"android":{"default":{"projectId":"school-display-ff28f","appId":"1:137322214849:android:2595999513206bdeb16b40","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"school-display-ff28f","configurations":{"android":"1:137322214849:android:2595999513206bdeb16b40","web":"1:137322214849:web:0423e5c788d95761b16b40"}}}}},"hosting":{"public":"build/web","ignore":["firebase.json","**/.*"],"rewrites":[{"source":"**","destination":"/index.html"}],"headers":[{"source":"/index.html","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]},{"source":"/flutter_service_worker.js","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]},{"source":"/version.json","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]}]}}
|
||||
{
|
||||
"flutter": {
|
||||
"platforms": {
|
||||
"android": {
|
||||
"default": {
|
||||
"projectId": "school-display-ff28f",
|
||||
"appId": "1:137322214849:android:2595999513206bdeb16b40",
|
||||
"fileOutput": "android/app/google-services.json"
|
||||
}
|
||||
},
|
||||
"dart": {
|
||||
"lib/firebase_options.dart": {
|
||||
"projectId": "school-display-ff28f",
|
||||
"configurations": {
|
||||
"android": "1:137322214849:android:2595999513206bdeb16b40",
|
||||
"web": "1:137322214849:web:0423e5c788d95761b16b40"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"hosting": {
|
||||
"public": "build/web",
|
||||
"ignore": [
|
||||
"firebase.json",
|
||||
"**/.*"
|
||||
],
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "**",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
],
|
||||
"headers": [
|
||||
{
|
||||
"source": "/index.html",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "no-cache, no-store, must-revalidate"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "no-cache, no-store, must-revalidate"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/flutter_service_worker.js",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "no-cache, no-store, must-revalidate"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/version.json",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "no-cache, no-store, must-revalidate"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// 📅 선생님 호출 화면에서 "지금 수업 중" 배지를 보여주기 위한 시간표 데이터.
|
||||
// 원래 별도 Firebase 페이지(index.html)에 있던 데이터를 그대로 옮기면서,
|
||||
// "김지" 약어가 실제로는 조신현 선생님을 가리키던 오타를 "조신"으로 바로잡았다.
|
||||
// (다른 모든 약어는 이름 앞 두 글자 규칙을 따르는데 이것만 깨져 있었음)
|
||||
class PeriodTime {
|
||||
final int period;
|
||||
final String start;
|
||||
final String end;
|
||||
const PeriodTime(this.period, this.start, this.end);
|
||||
}
|
||||
|
||||
const List<PeriodTime> kPeriodSettings = [
|
||||
PeriodTime(1, "08:40", "09:30"),
|
||||
PeriodTime(2, "09:40", "10:30"),
|
||||
PeriodTime(3, "10:40", "11:30"),
|
||||
PeriodTime(4, "11:40", "12:30"),
|
||||
PeriodTime(5, "13:30", "14:20"),
|
||||
PeriodTime(6, "14:30", "15:20"),
|
||||
PeriodTime(7, "15:30", "16:20"),
|
||||
];
|
||||
|
||||
// 이름 앞 두 글자 약어 → 전체 이름. ("조신":"조신현" 으로 수정됨 - 원래 "김지"였던 오타)
|
||||
const Map<String, String> kTeacherAbbrevMap = {
|
||||
"손계": "손계강",
|
||||
"조신": "조신현",
|
||||
"진경": "진경아",
|
||||
"김만": "김만중",
|
||||
"이길": "이길석",
|
||||
"김효": "김효정",
|
||||
"이부": "이부원",
|
||||
"고경": "고경철",
|
||||
"김경": "김경원",
|
||||
"양성": "양성욱",
|
||||
"김명": "김명선",
|
||||
"김현": "김현진",
|
||||
"박은": "박은호",
|
||||
"심윤": "심윤주",
|
||||
"조영": "조영희",
|
||||
"이정": "이정상",
|
||||
"조용": "조용우",
|
||||
"이민": "이민혁",
|
||||
};
|
||||
|
||||
// day: 1=월 ~ 5=금, period: 1~7. 값은 그 시간에 수업 중인 선생님 약어 목록.
|
||||
const Map<int, Map<int, List<String>>> kFullTimetable = {
|
||||
1: {
|
||||
1: ["손계", "조신", "진경", "김만", "이길", "김효", "이부"],
|
||||
2: ["김경", "손계", "이길", "조영", "김효", "양성", "김명", "김현"],
|
||||
3: ["박은", "심윤", "조영", "고경", "이정", "이부", "김경"],
|
||||
4: ["심윤", "진경", "김효", "이길", "이정", "김만", "김명", "김현", "양성"],
|
||||
5: ["박은", "손계", "이정", "고경", "조영", "김현", "양성", "김명"],
|
||||
6: ["이민", "심윤", "고경", "김만", "이길", "이부", "김경"],
|
||||
7: ["이민", "김경", "박은", "김만", "이길", "조영", "양성", "조신", "손계"],
|
||||
},
|
||||
2: {
|
||||
1: ["손계", "이정", "박은", "김만", "심윤", "이길", "조영", "김현", "조신", "양성"],
|
||||
2: ["김효", "김경", "이정", "고경", "조영", "김만", "양성", "김현", "김명"],
|
||||
3: ["진경", "심윤", "손계", "이민", "이부", "김명", "조용", "조신"],
|
||||
4: ["이정", "진경", "김효", "이민", "이부", "조용", "양성", "김현"],
|
||||
5: ["박은", "김효", "심윤", "이민", "이부", "김명", "김현", "손계"],
|
||||
6: ["김경", "박은", "진경", "이민", "이부", "양성", "김명", "조용"],
|
||||
7: ["진경", "손계", "이부", "김만", "이길", "김효", "박은", "김경", "김현"],
|
||||
},
|
||||
3: {
|
||||
1: ["김효", "김경", "이민", "이부", "김명", "김현", "이정"],
|
||||
2: ["조용", "진경", "김효", "조영", "김만", "이길", "이부", "고경"],
|
||||
3: ["심윤", "손계", "조용", "이민", "박은", "조신", "양성", "이정", "김명"],
|
||||
4: ["진경", "김효", "심윤", "이민", "박은", "조신", "이정", "양성", "김현"],
|
||||
},
|
||||
4: {
|
||||
1: ["심윤", "김경", "조신", "조영", "이길", "김만", "이정", "양성", "김명"],
|
||||
2: ["박은", "진경", "김만", "심윤", "이길", "조영", "이부", "고경"],
|
||||
3: ["진경", "김효", "이민", "이부", "김현", "김명", "이정"],
|
||||
4: ["손계", "김효", "김경", "이민", "박은", "조신", "양성", "이정", "김현"],
|
||||
5: ["조신", "진경", "심윤", "김만", "고경", "조영", "이부", "이민"],
|
||||
6: ["김경", "심윤", "손계", "이길", "조영", "김효", "박은", "진경", "조신"],
|
||||
7: ["김효", "손계", "박은", "고경", "김만", "이길", "김현", "김명", "양성"],
|
||||
},
|
||||
5: {
|
||||
1: ["손계", "박은", "이정", "김만", "조영", "이길", "김현", "김명", "양성"],
|
||||
2: ["이정", "손계", "김경", "이길", "고경", "조영", "김명", "조신", "김현"],
|
||||
3: ["진경", "심윤", "조용", "고경", "김만", "김효", "이부", "김경"],
|
||||
4: ["조용", "이정", "진경", "김만", "심윤", "이길", "조영", "양성", "김현", "김명"],
|
||||
5: ["진경", "손계", "이부", "조용", "조신", "고경", "박은", "김경", "김현"],
|
||||
7: ["김효", "조용", "손계", "조영", "이길", "김만", "이부", "고경"],
|
||||
},
|
||||
};
|
||||
|
||||
/// 지금 이 순간(day: 1~5, HH:mm) 수업 중인 선생님 전체 이름 목록. 주말/쉬는시간이면 빈 목록.
|
||||
List<String> teachersInClassNow(int weekday, String hhmm) {
|
||||
if (weekday < 1 || weekday > 5) return [];
|
||||
PeriodTime? current;
|
||||
for (final p in kPeriodSettings) {
|
||||
if (hhmm.compareTo(p.start) >= 0 && hhmm.compareTo(p.end) <= 0) {
|
||||
current = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (current == null) return [];
|
||||
final abbrevs = kFullTimetable[weekday]?[current.period] ?? [];
|
||||
return abbrevs.map((a) => kTeacherAbbrevMap[a]).whereType<String>().toList();
|
||||
}
|
||||
|
||||
const Map<String, IconData> kLocationIcons = {
|
||||
"교무실": Icons.business_rounded,
|
||||
"급식실": Icons.restaurant_rounded,
|
||||
"과학실": Icons.science_rounded,
|
||||
"도서관": Icons.menu_book_rounded,
|
||||
"출장": Icons.directions_car_rounded,
|
||||
"조퇴/퇴근": Icons.home_rounded,
|
||||
"상담실": Icons.forum_rounded,
|
||||
"기숙사": Icons.hotel_rounded,
|
||||
};
|
||||
|
||||
const List<String> kLocationOptions = [
|
||||
"교무실",
|
||||
"급식실",
|
||||
"과학실",
|
||||
"도서관",
|
||||
"출장",
|
||||
"조퇴/퇴근",
|
||||
"상담실",
|
||||
"기숙사",
|
||||
];
|
||||
|
||||
const List<String> kCallPurposes = [
|
||||
"간단한 용무",
|
||||
"질문 있어요",
|
||||
"상담 신청",
|
||||
"과제 제출",
|
||||
"물건 전달",
|
||||
];
|
||||
@@ -0,0 +1,76 @@
|
||||
// 🗣️ 커뮤니티 게시판 - 카테고리별 게시글 목록 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
class BoardListController extends ChangeNotifier {
|
||||
final String category;
|
||||
final String studentId;
|
||||
|
||||
BoardListController({required this.category, required this.studentId});
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isLoadingMore = false;
|
||||
bool _hasMore = true;
|
||||
int _page = 1;
|
||||
List<dynamic> _posts = [];
|
||||
bool _disposed = false;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isLoadingMore => _isLoadingMore;
|
||||
bool get hasMore => _hasMore;
|
||||
List<dynamic> get posts => _posts;
|
||||
|
||||
static const int _pageSize = 20;
|
||||
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> init() => refresh();
|
||||
|
||||
Future<void> refresh() async {
|
||||
_isLoading = true;
|
||||
_page = 1;
|
||||
_hasMore = true;
|
||||
_safeNotify();
|
||||
await _fetchPage(replace: true);
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (_isLoadingMore || !_hasMore || _isLoading) return;
|
||||
_isLoadingMore = true;
|
||||
_safeNotify();
|
||||
_page += 1;
|
||||
await _fetchPage(replace: false);
|
||||
_isLoadingMore = false;
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
Future<void> _fetchPage({required bool replace}) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse(
|
||||
'$baseUrl/api/board/posts?category=$category&studentId=$studentId&page=$_page',
|
||||
),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
final fetched = (data['posts'] as List<dynamic>?) ?? [];
|
||||
_posts = replace ? fetched : [..._posts, ...fetched];
|
||||
_hasMore = fetched.length >= _pageSize;
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시 - 마지막으로 받아온 목록을 유지한다.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// 💬 커뮤니티 게시판 - 게시글 상세(+댓글) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
class BoardPostController extends ChangeNotifier {
|
||||
final int postId;
|
||||
final String studentId;
|
||||
|
||||
BoardPostController({required this.postId, required this.studentId});
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isBusy = false;
|
||||
bool _postDeleted = false;
|
||||
Map<String, dynamic>? _post;
|
||||
List<dynamic> _comments = [];
|
||||
bool _disposed = false;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isBusy => _isBusy;
|
||||
bool get postDeleted => _postDeleted;
|
||||
Map<String, dynamic>? get post => _post;
|
||||
List<dynamic> get comments => _comments;
|
||||
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> init() => fetchDetail();
|
||||
|
||||
Future<void> fetchDetail() async {
|
||||
_isLoading = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/board/posts/$postId?studentId=$studentId'),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||
_post = Map<String, dynamic>.from(result['post']);
|
||||
_comments = result['comments'] ?? [];
|
||||
} else {
|
||||
_postDeleted = true;
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시 - 마지막으로 받아온 상태를 유지한다.
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> addComment(String content) async {
|
||||
if (content.trim().isEmpty) return (false, '댓글 내용을 입력해주세요.');
|
||||
_isBusy = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/board/posts/$postId/comments'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"studentId": studentId, "content": content.trim()}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||
await fetchDetail();
|
||||
return (true, '댓글이 등록되었습니다.');
|
||||
}
|
||||
return (false, '${result['message'] ?? '댓글 등록 실패'}');
|
||||
} catch (e) {
|
||||
return (false, '네트워크 에러: $e');
|
||||
} finally {
|
||||
_isBusy = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> deletePost() async {
|
||||
return _delete('$baseUrl/api/board/posts/$postId');
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> deleteComment(int commentId) async {
|
||||
final (success, message) = await _delete(
|
||||
'$baseUrl/api/board/comments/$commentId',
|
||||
);
|
||||
if (success) await fetchDetail();
|
||||
return (success, message);
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> _delete(String url) async {
|
||||
_isBusy = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.delete(
|
||||
Uri.parse(url),
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> report({
|
||||
required String targetType,
|
||||
required int targetId,
|
||||
String? reason,
|
||||
}) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/board/reports'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({
|
||||
"targetType": targetType,
|
||||
"targetId": targetId,
|
||||
"reporterStudentId": studentId,
|
||||
"reason": reason,
|
||||
}),
|
||||
);
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// 🚩 커뮤니티 게시판 - 신고 검토(운영자용) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
class BoardReportController extends ChangeNotifier {
|
||||
final String reviewerId;
|
||||
|
||||
BoardReportController({required this.reviewerId});
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isBusy = false;
|
||||
List<dynamic> _reports = [];
|
||||
bool _disposed = false;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isBusy => _isBusy;
|
||||
List<dynamic> get reports => _reports;
|
||||
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> init() => fetchPending();
|
||||
|
||||
Future<void> fetchPending() async {
|
||||
_isLoading = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/board/reports?status=pending'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_reports = data['reports'] ?? [];
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시 - 마지막으로 받아온 목록을 유지한다.
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> resolve(
|
||||
int reportId,
|
||||
String action,
|
||||
) async {
|
||||
_isBusy = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/board/reports/$reportId/resolve'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"reviewerId": reviewerId, "action": action}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||
await fetchPending();
|
||||
return (true, '${result['message'] ?? '처리되었습니다.'}');
|
||||
}
|
||||
return (false, '${result['message'] ?? '처리 실패'}');
|
||||
} catch (e) {
|
||||
return (false, '네트워크 에러: $e');
|
||||
} finally {
|
||||
_isBusy = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// ✍️ 커뮤니티 게시판 - 글쓰기 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
class BoardWriteController extends ChangeNotifier {
|
||||
final String studentId;
|
||||
|
||||
BoardWriteController({required this.studentId});
|
||||
|
||||
bool _isSubmitting = false;
|
||||
bool get isSubmitting => _isSubmitting;
|
||||
|
||||
Future<(bool success, String message)> submit({
|
||||
required String category,
|
||||
required String title,
|
||||
required String content,
|
||||
}) async {
|
||||
if (title.trim().isEmpty || content.trim().isEmpty) {
|
||||
return (false, '제목과 내용을 입력해주세요.');
|
||||
}
|
||||
_isSubmitting = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/board/posts'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({
|
||||
"studentId": studentId,
|
||||
"category": category,
|
||||
"title": title.trim(),
|
||||
"content": content.trim(),
|
||||
}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||
return (true, '게시글이 등록되었습니다.');
|
||||
}
|
||||
return (false, '${result['message'] ?? '게시글 등록 실패'}');
|
||||
} catch (e) {
|
||||
return (false, '네트워크 에러: $e');
|
||||
} finally {
|
||||
_isSubmitting = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// 🌐 플랫폼에 따라 웹 전용 구현/무동작 구현 중 하나로 연결되는 진입점.
|
||||
export 'browser_notification_stub.dart'
|
||||
if (dart.library.html) 'browser_notification_web.dart';
|
||||
@@ -0,0 +1,4 @@
|
||||
// 🌐 웹이 아닌 빌드(Android 등)에서는 브라우저 알림 API 자체가 없으니 아무 동작도 안 한다.
|
||||
Future<bool> requestNotificationPermission() async => false;
|
||||
|
||||
void showBrowserNotification(String title, String body) {}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 🌐 웹 전용 - 브라우저 알림(Notification) API로 "타이머 켜놓고 탭을 벗어났을 때" 같은
|
||||
// 즉각적인 로컬 알림을 띄운다. FCM 푸시와 달리 서버 왕복 없이 바로 뜬다.
|
||||
// 알림 API를 지원하지 않는 브라우저에서는 조용히 무시한다.
|
||||
import 'dart:js_interop';
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
Future<bool> requestNotificationPermission() async {
|
||||
try {
|
||||
if (web.Notification.permission == 'granted') return true;
|
||||
final permission = await web.Notification.requestPermission().toDart;
|
||||
return permission.toDart == 'granted';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void showBrowserNotification(String title, String body) {
|
||||
try {
|
||||
if (web.Notification.permission != 'granted') return;
|
||||
web.Notification(title, web.NotificationOptions(body: body));
|
||||
} catch (_) {
|
||||
// 조용히 무시
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,39 @@ class DeviceCheckoutLedgerController extends ChangeNotifier {
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isWorking => _isWorking;
|
||||
|
||||
// 🚨 [장시간 요청 기준] 1시간 59분(119분) 이상이면 "많은 시간요청"으로 본다.
|
||||
static const int longRequestMinutes = 119;
|
||||
|
||||
int? _durationMinutes(dynamic request) {
|
||||
try {
|
||||
final start = DateTime.parse(request['requestedStart']);
|
||||
final end = DateTime.parse(request['requestedEnd']);
|
||||
final minutes = end.difference(start).inMinutes;
|
||||
return minutes > 0 ? minutes : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// ⏰ 요청 시간이 얼마나 되는지(시간 단위). 형식이 이상하면 null.
|
||||
double? durationHours(dynamic request) {
|
||||
final minutes = _durationMinutes(request);
|
||||
return minutes == null ? null : minutes / 60.0;
|
||||
}
|
||||
|
||||
/// 🚨 [장시간 요청 감지] 1시간 59분 넘게 신청했는지. 상태(대기/승인 등)와 무관하게 판단한다 -
|
||||
/// 목록 카드에 빨간 테두리를 씌우는 데 쓴다.
|
||||
bool isLongRequest(dynamic request) {
|
||||
final minutes = _durationMinutes(request);
|
||||
return minutes != null && minutes >= longRequestMinutes;
|
||||
}
|
||||
|
||||
/// 학생이 시간을 비정상적으로 길게 적어냈을 수 있으니, 아직 대기 중인 장시간 요청만
|
||||
/// 따로 모아서 전체 승인 전에 훑어볼 수 있게 한다.
|
||||
List<dynamic> get longPendingRequests => _requests
|
||||
.where((r) => r['status'] == 'PENDING' && isLongRequest(r))
|
||||
.toList();
|
||||
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
@@ -63,6 +96,69 @@ class DeviceCheckoutLedgerController extends ChangeNotifier {
|
||||
});
|
||||
}
|
||||
|
||||
/// 🙌 [전체 학생 허용] 대상이 될 요청 id 목록 (대기 중이면서 장시간 요청은 아닌 것만).
|
||||
/// 확인 다이얼로그에서 몇 건이 승인되고 몇 건이 제외되는지 미리 보여주는 데 쓴다.
|
||||
List<int> get autoApprovableIds => _requests
|
||||
.where((r) => r['status'] == 'PENDING' && !isLongRequest(r))
|
||||
.map<int>((r) => r['id'] as int)
|
||||
.toList();
|
||||
|
||||
// 🙌 [전체 학생 허용] 대기 중인 요청을 한 명씩 누르기 귀찮을 때, 한 번에 전부 승인한다.
|
||||
// 단, 1시간 59분 넘는 장시간 요청은 자동으로 제외하고 대기중 상태로 남겨서,
|
||||
// "많은 시간요청 학생" 목록에서 따로 검토/승인하게 한다.
|
||||
// 서버에 일괄 승인 API가 따로 없어서, 대상 요청마다 승인 요청을 순서대로 보낸다.
|
||||
Future<(bool success, String message)> approveAllPending({
|
||||
required String teacherId,
|
||||
required String teacherName,
|
||||
}) async {
|
||||
final targetIds = autoApprovableIds;
|
||||
final excludedCount = longPendingRequests.length;
|
||||
|
||||
if (targetIds.isEmpty) {
|
||||
return (
|
||||
true,
|
||||
excludedCount > 0
|
||||
? '장시간 요청 $excludedCount건을 제외하면 승인할 대기 요청이 없습니다.'
|
||||
: '승인 대기 중인 요청이 없습니다.',
|
||||
);
|
||||
}
|
||||
|
||||
_isWorking = true;
|
||||
_safeNotify();
|
||||
int successCount = 0;
|
||||
for (final id in targetIds) {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/device-checkout/approve'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({
|
||||
"requestId": id,
|
||||
"teacherId": teacherId,
|
||||
"teacherName": teacherName,
|
||||
}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||
successCount++;
|
||||
}
|
||||
} catch (_) {
|
||||
// 개별 요청 실패는 건너뛰고 나머지는 계속 진행한다.
|
||||
}
|
||||
}
|
||||
await fetchAll();
|
||||
_isWorking = false;
|
||||
_safeNotify();
|
||||
|
||||
final allOk = successCount == targetIds.length;
|
||||
final excludedNote = excludedCount > 0
|
||||
? ' (장시간 요청 $excludedCount건은 제외 - 대기중으로 남음)'
|
||||
: '';
|
||||
return (
|
||||
allOk,
|
||||
'${targetIds.length}건 중 $successCount건 승인 완료${allOk ? '' : ' (일부 실패)'}$excludedNote',
|
||||
);
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> reject(int requestId) async {
|
||||
return _postAction('/api/device-checkout/reject', {"requestId": requestId});
|
||||
}
|
||||
|
||||
@@ -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<dynamic> _friends = [];
|
||||
List<dynamic> _requests = [];
|
||||
bool _disposed = false;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isBusy => _isBusy;
|
||||
List<dynamic> get friends => _friends;
|
||||
List<dynamic> get requests => _requests;
|
||||
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
_isLoading = true;
|
||||
_safeNotify();
|
||||
await Future.wait([_fetchFriends(), _fetchRequests()]);
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// 👥 백품타 그룹 스터디(목록/생성/초대) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
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<dynamic> _groups = [];
|
||||
List<dynamic> _invites = [];
|
||||
List<dynamic> _publicGroups = [];
|
||||
bool _disposed = false;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isBusy => _isBusy;
|
||||
List<dynamic> get groups => _groups;
|
||||
List<dynamic> get invites => _invites;
|
||||
List<dynamic> get publicGroups => _publicGroups;
|
||||
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> init() => refresh();
|
||||
|
||||
Future<void> refresh() async {
|
||||
_isLoading = true;
|
||||
_safeNotify();
|
||||
await Future.wait([_fetchGroups(), _fetchInvites(), _fetchPublicGroups()]);
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _fetchPublicGroups() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/groups/public?studentId=$studentId'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_publicGroups = data['groups'] ?? [];
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> createGroup(
|
||||
String name, {
|
||||
bool isPublic = false,
|
||||
}) 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(),
|
||||
"isPublic": isPublic,
|
||||
}),
|
||||
);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> requestJoin(int groupId) async {
|
||||
_isBusy = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/groups/$groupId/join-request'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"studentId": studentId, "studentName": studentName}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||
await _fetchPublicGroups();
|
||||
return (true, '${result['message'] ?? '참여 신청을 보냈습니다.'}');
|
||||
}
|
||||
return (false, '${result['message'] ?? '참여 신청 실패'}');
|
||||
} catch (e) {
|
||||
return (false, '네트워크 에러: $e');
|
||||
} finally {
|
||||
_isBusy = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// 👥 백품타 그룹 상세(멤버/오늘 출석/초대/나가기) 화면의 기능 담당 컨트롤러.
|
||||
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<dynamic> _members = [];
|
||||
List<dynamic> _joinRequests = [];
|
||||
bool _isPublic = false;
|
||||
bool _disposed = false;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isBusy => _isBusy;
|
||||
List<dynamic> get members => _members;
|
||||
List<dynamic> get joinRequests => _joinRequests;
|
||||
bool get isPublic => _isPublic;
|
||||
bool get isOwner =>
|
||||
_members.any((m) => m['studentId'] == studentId && m['role'] == 'owner');
|
||||
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> init() => refresh();
|
||||
|
||||
Future<void> 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'] ?? [];
|
||||
_isPublic = data['isPublic'] == true;
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시
|
||||
}
|
||||
if (isOwner) await _fetchJoinRequests();
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
Future<void> _fetchJoinRequests() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse(
|
||||
'$baseUrl/api/groups/$groupId/join-requests?ownerId=$studentId',
|
||||
),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_joinRequests = data['requests'] ?? [];
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> respondJoinRequest(
|
||||
int requestId,
|
||||
bool accept,
|
||||
) async {
|
||||
_isBusy = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/groups/join-requests/$requestId/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();
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> setVisibility(bool isPublic) async {
|
||||
_isBusy = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/groups/$groupId/visibility'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"ownerId": studentId, "isPublic": isPublic}),
|
||||
);
|
||||
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)> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,18 +16,21 @@ class LoginResult {
|
||||
final String? name;
|
||||
final bool isDeviceMatched;
|
||||
final String? errorMessage;
|
||||
final int? grade; // 🎓 공부 타이머 학년별 랭킹에서 본인 학년을 기본값으로 쓰기 위함.
|
||||
|
||||
const LoginResult.masterSuccess({required this.studentId, required this.name})
|
||||
: outcome = LoginOutcome.masterSuccess,
|
||||
role = null,
|
||||
isDeviceMatched = true,
|
||||
errorMessage = null;
|
||||
errorMessage = null,
|
||||
grade = null;
|
||||
|
||||
const LoginResult.needsPasswordChange({
|
||||
required this.studentId,
|
||||
required this.name,
|
||||
required this.role,
|
||||
required this.isDeviceMatched,
|
||||
this.grade,
|
||||
}) : outcome = LoginOutcome.needsPasswordChange,
|
||||
errorMessage = null;
|
||||
|
||||
@@ -36,6 +39,7 @@ class LoginResult {
|
||||
required this.studentId,
|
||||
required this.name,
|
||||
required this.isDeviceMatched,
|
||||
this.grade,
|
||||
}) : outcome = LoginOutcome.success,
|
||||
errorMessage = null;
|
||||
|
||||
@@ -44,7 +48,8 @@ class LoginResult {
|
||||
role = null,
|
||||
studentId = null,
|
||||
name = null,
|
||||
isDeviceMatched = false;
|
||||
isDeviceMatched = false,
|
||||
grade = null;
|
||||
}
|
||||
|
||||
class LoginController extends ChangeNotifier {
|
||||
@@ -101,6 +106,7 @@ class LoginController extends ChangeNotifier {
|
||||
String name = '';
|
||||
String studentId = '';
|
||||
int isFirstLogin = 0;
|
||||
int? grade;
|
||||
|
||||
var userObj = resData['user'];
|
||||
if (userObj != null) {
|
||||
@@ -114,6 +120,7 @@ class LoginController extends ChangeNotifier {
|
||||
userObj['studentId']?.toString() ??
|
||||
userObj['student_id']?.toString() ??
|
||||
id;
|
||||
grade = int.tryParse(userObj['grade']?.toString() ?? '');
|
||||
|
||||
var rawFirst = userObj['isFirstLogin'] ?? userObj['is_first_login'];
|
||||
isFirstLogin = (rawFirst is bool)
|
||||
@@ -126,6 +133,7 @@ class LoginController extends ChangeNotifier {
|
||||
resData['studentName']?.toString() ??
|
||||
'';
|
||||
studentId = resData['studentId']?.toString() ?? id;
|
||||
grade = int.tryParse(resData['grade']?.toString() ?? '');
|
||||
|
||||
var rawFirst = resData['isFirstLogin'] ?? resData['is_first_login'];
|
||||
isFirstLogin = (rawFirst is bool)
|
||||
@@ -153,6 +161,7 @@ class LoginController extends ChangeNotifier {
|
||||
name: name,
|
||||
role: role,
|
||||
isDeviceMatched: isDeviceMatched,
|
||||
grade: grade,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,6 +170,7 @@ class LoginController extends ChangeNotifier {
|
||||
studentId: studentId,
|
||||
name: name,
|
||||
isDeviceMatched: isDeviceMatched,
|
||||
grade: grade,
|
||||
);
|
||||
} else {
|
||||
String errorMsg = '로그인에 실패했습니다.';
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// 🛍️ 프로필 꾸미기 상점 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
// UI는 lib/ui/profile_customize_screen.dart가 담당한다.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' show Color;
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
import '../theme/profile_themes.dart';
|
||||
|
||||
class ShopTemplate {
|
||||
final int id;
|
||||
final String code;
|
||||
final String category;
|
||||
final String name;
|
||||
final int price;
|
||||
final Color primaryColor;
|
||||
final Color secondaryColor;
|
||||
final bool owned;
|
||||
final bool equipped;
|
||||
|
||||
ShopTemplate({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.category,
|
||||
required this.name,
|
||||
required this.price,
|
||||
required this.primaryColor,
|
||||
required this.secondaryColor,
|
||||
required this.owned,
|
||||
required this.equipped,
|
||||
});
|
||||
|
||||
factory ShopTemplate.fromJson(Map<String, dynamic> json) {
|
||||
return ShopTemplate(
|
||||
id: json['id'] as int,
|
||||
code: json['code'] as String,
|
||||
category: json['category'] as String,
|
||||
name: json['name'] as String,
|
||||
price: json['price'] as int,
|
||||
primaryColor: _parseColor(json['primaryColor'] as String),
|
||||
secondaryColor: _parseColor(json['secondaryColor'] as String),
|
||||
owned: json['owned'] == true,
|
||||
equipped: json['equipped'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Color _parseColor(String hex) {
|
||||
return Color(int.parse(hex.substring(2), radix: 16));
|
||||
}
|
||||
|
||||
class ProfileShopController extends ChangeNotifier {
|
||||
bool _isLoading = false;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
int cashBalance = 0;
|
||||
List<ShopTemplate> templates = [];
|
||||
|
||||
void _safeNotify() {
|
||||
if (hasListeners) notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadCatalog(String studentId) async {
|
||||
_isLoading = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final url = Uri.parse(
|
||||
'$baseUrl/api/shop/catalog?studentId=${Uri.encodeComponent(studentId)}',
|
||||
);
|
||||
final response = await http.get(url);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
cashBalance = (data['cashBalance'] as num?)?.toInt() ?? 0;
|
||||
templates = ((data['templates'] as List?) ?? [])
|
||||
.map((t) => ShopTemplate.fromJson(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
} catch (_) {
|
||||
// 네트워크 에러여도 화면은 빈 상태로라도 그려지게 조용히 넘어간다.
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> purchase(
|
||||
String studentId,
|
||||
int templateId,
|
||||
) async {
|
||||
try {
|
||||
final url = Uri.parse('$baseUrl/api/shop/purchase');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'studentId': studentId, 'templateId': templateId}),
|
||||
);
|
||||
final data = jsonDecode(response.body);
|
||||
final success = data['status'] == 'success';
|
||||
if (success) {
|
||||
await loadCatalog(studentId);
|
||||
}
|
||||
return (success, '${data['message'] ?? (success ? '구매 완료!' : '구매 실패')}');
|
||||
} catch (e) {
|
||||
return (false, '서버와 연결할 수 없습니다. (네트워크 에러)');
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> equip(
|
||||
String studentId,
|
||||
int? templateId,
|
||||
) async {
|
||||
try {
|
||||
final url = Uri.parse('$baseUrl/api/shop/equip');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'studentId': studentId, 'templateId': templateId}),
|
||||
);
|
||||
final data = jsonDecode(response.body);
|
||||
final success = data['status'] == 'success';
|
||||
if (success) {
|
||||
await loadCatalog(studentId);
|
||||
}
|
||||
return (success, '${data['message'] ?? (success ? '장착 완료' : '장착 실패')}');
|
||||
} catch (e) {
|
||||
return (false, '서버와 연결할 수 없습니다. (네트워크 에러)');
|
||||
}
|
||||
}
|
||||
|
||||
/// 🏠 메인 대시보드 프로필 카드 렌더링 전용 - 상점 화면을 열지 않고도
|
||||
/// 현재 장착 중인 테마 색상만 가볍게 조회한다.
|
||||
static Future<ProfileTheme?> fetchEquippedTheme(String studentId) async {
|
||||
try {
|
||||
final url = Uri.parse(
|
||||
'$baseUrl/api/shop/equipped?studentId=${Uri.encodeComponent(studentId)}',
|
||||
);
|
||||
final response = await http.get(url);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
if (data['equipped'] == true) {
|
||||
return profileThemes[data['code'] as String?];
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// 실패하면 그냥 기본 모습으로 보여준다.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,14 @@ class SavedSession {
|
||||
final String userName;
|
||||
final String role;
|
||||
final bool isDeviceMatched;
|
||||
final int? grade; // 🎓 공부 타이머 학년별 랭킹에서 본인 학년을 기본값으로 쓰기 위함.
|
||||
|
||||
const SavedSession({
|
||||
required this.userId,
|
||||
required this.userName,
|
||||
required this.role,
|
||||
required this.isDeviceMatched,
|
||||
this.grade,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -22,6 +24,7 @@ class SavedSession {
|
||||
'userName': userName,
|
||||
'role': role,
|
||||
'isDeviceMatched': isDeviceMatched,
|
||||
'grade': grade,
|
||||
};
|
||||
|
||||
factory SavedSession.fromJson(Map<String, dynamic> json) => SavedSession(
|
||||
@@ -29,6 +32,7 @@ class SavedSession {
|
||||
userName: json['userName'] as String,
|
||||
role: json['role'] as String,
|
||||
isDeviceMatched: json['isDeviceMatched'] as bool? ?? true,
|
||||
grade: json['grade'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// 📅 백품타 출석 달력(개인 스트릭 + 관리자 이벤트) 화면의 기능 담당 컨트롤러.
|
||||
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;
|
||||
int _attendanceThresholdSeconds = 3600;
|
||||
Set<String> _studiedDates = {};
|
||||
Map<String, dynamic> _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;
|
||||
int get attendanceThresholdSeconds => _attendanceThresholdSeconds;
|
||||
Set<String> get studiedDates => _studiedDates;
|
||||
Map<String, dynamic> get events => _events;
|
||||
DateTime get visibleMonth => _visibleMonth;
|
||||
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> init() => refresh();
|
||||
|
||||
Future<void> goToPreviousMonth() async {
|
||||
_visibleMonth = DateTime(_visibleMonth.year, _visibleMonth.month - 1);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> goToNextMonth() async {
|
||||
_visibleMonth = DateTime(_visibleMonth.year, _visibleMonth.month + 1);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
_isLoading = true;
|
||||
_safeNotify();
|
||||
await Future.wait([_fetchStreak(), _fetchEvents()]);
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
Future<void> _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;
|
||||
_attendanceThresholdSeconds =
|
||||
(data['attendanceThresholdSeconds'] as num?)?.toInt() ?? 3600;
|
||||
_studiedDates = ((data['studiedDates'] as List<dynamic>?) ?? [])
|
||||
.map((d) => d.toString())
|
||||
.toSet();
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<dynamic> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// 🍅 뽀모도로 루틴 공유(다른 학생과 공부/휴식 시간 조합 공유) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
class StudyPresetController extends ChangeNotifier {
|
||||
final String studentId;
|
||||
final String studentName;
|
||||
|
||||
StudyPresetController({required this.studentId, required this.studentName});
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isBusy = false;
|
||||
String _sort = 'popular'; // popular / recent
|
||||
List<dynamic> _presets = [];
|
||||
bool _disposed = false;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isBusy => _isBusy;
|
||||
String get sort => _sort;
|
||||
List<dynamic> get presets => _presets;
|
||||
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> init() => fetchPresets();
|
||||
|
||||
Future<void> setSort(String sort) async {
|
||||
if (_sort == sort) return;
|
||||
_sort = sort;
|
||||
await fetchPresets();
|
||||
}
|
||||
|
||||
Future<void> fetchPresets() async {
|
||||
_isLoading = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/study/presets?sort=$_sort'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_presets = data['presets'] ?? [];
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시 - 마지막으로 받아온 목록을 유지한다.
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> share({
|
||||
required String title,
|
||||
required int studyMinutes,
|
||||
required int breakMinutes,
|
||||
}) async {
|
||||
_isBusy = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/study/presets'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({
|
||||
"studentId": studentId,
|
||||
"studentName": studentName,
|
||||
"title": title.trim(),
|
||||
"studyMinutes": studyMinutes,
|
||||
"breakMinutes": breakMinutes,
|
||||
}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||
await fetchPresets();
|
||||
return (true, '${result['message'] ?? '루틴을 공유했습니다.'}');
|
||||
}
|
||||
return (false, '${result['message'] ?? '루틴 공유 실패'}');
|
||||
} catch (e) {
|
||||
return (false, '네트워크 에러: $e');
|
||||
} finally {
|
||||
_isBusy = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 다른 학생의 루틴을 내 설정으로 적용할 때 호출 - 인기도(사용 횟수)만 올려준다.
|
||||
Future<void> markUsed(int presetId) async {
|
||||
try {
|
||||
await http.post(Uri.parse('$baseUrl/api/study/presets/$presetId/use'));
|
||||
} catch (_) {
|
||||
// 인기도 집계 실패는 조용히 무시 - 적용 자체는 이미 끝난 뒤라 영향 없다.
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> delete(int presetId) async {
|
||||
_isBusy = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.delete(
|
||||
Uri.parse('$baseUrl/api/study/presets/$presetId'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"studentId": studentId}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||
await fetchPresets();
|
||||
return (true, '${result['message'] ?? '삭제되었습니다.'}');
|
||||
}
|
||||
return (false, '${result['message'] ?? '삭제 실패'}');
|
||||
} catch (e) {
|
||||
return (false, '네트워크 에러: $e');
|
||||
} finally {
|
||||
_isBusy = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// 🏆 학년별 공부시간 랭킹 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
class StudyRankingController extends ChangeNotifier {
|
||||
final int grade;
|
||||
|
||||
StudyRankingController({required this.grade});
|
||||
|
||||
bool _isLoading = true;
|
||||
String _period = 'today'; // today / week / total
|
||||
String _scope = 'grade'; // grade / all
|
||||
List<dynamic> _ranking = [];
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
String get period => _period;
|
||||
String get scope => _scope;
|
||||
List<dynamic> get ranking => _ranking;
|
||||
|
||||
Future<void> init() => fetchRanking();
|
||||
|
||||
Future<void> setPeriod(String period) async {
|
||||
if (_period == period) return;
|
||||
_period = period;
|
||||
await fetchRanking();
|
||||
}
|
||||
|
||||
Future<void> setScope(String scope) async {
|
||||
if (_scope == scope) return;
|
||||
_scope = scope;
|
||||
await fetchRanking();
|
||||
}
|
||||
|
||||
Future<void> fetchRanking() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final gradeParam = _scope == 'grade' ? '&grade=$grade' : '';
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/study/ranking?period=$_period$gradeParam'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_ranking = data['ranking'] ?? [];
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시 - 마지막으로 받아온 랭킹을 유지한다.
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// ⏱️ 우리 학교 전용 "백품타" 공부 타이머 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../config.dart';
|
||||
|
||||
enum StudyTimerState { idle, running, paused }
|
||||
|
||||
// 🍅 뽀모도로 진행 단계. study/break가 아니면(=null) 뽀모도로가 꺼져있거나 아직 시작 전.
|
||||
enum PomodoroPhase { study, breakTime }
|
||||
|
||||
const String _prefPomodoroEnabled = 'study_pomodoro_enabled';
|
||||
const String _prefPomodoroStudyMinutes = 'study_pomodoro_study_minutes';
|
||||
const String _prefPomodoroBreakMinutes = 'study_pomodoro_break_minutes';
|
||||
|
||||
class StudyTimerController extends ChangeNotifier {
|
||||
final String studentId;
|
||||
final String studentName;
|
||||
|
||||
StudyTimerController({required this.studentId, required this.studentName});
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isBusy = false; // 시작/일시정지/재개/종료 버튼 눌러서 서버 응답 기다리는 중
|
||||
StudyTimerState _state = StudyTimerState.idle;
|
||||
int _elapsedSeconds = 0; // 지금 세션에서 "정지 상태"일 때 확정된 누적 초 (일시정지 구간 제외)
|
||||
DateTime? _lastResumedAt; // running일 때만 의미 있음 - 여기서부터 실시간으로 흐른다
|
||||
int _todaySeconds = 0;
|
||||
int _weekSeconds = 0;
|
||||
int _totalSeconds = 0;
|
||||
Timer? _ticker;
|
||||
bool _disposed = false;
|
||||
|
||||
bool pomodoroEnabled = false;
|
||||
int studyMinutes = 50;
|
||||
int breakMinutes = 10;
|
||||
PomodoroPhase? _phase;
|
||||
// 🐛 [웹 탭 딜레이 버그 수정] 브라우저가 백그라운드 탭의 setInterval을 느리게(심하면 분당
|
||||
// 1번) 돌리기 때문에, 매 tick마다 초를 1씩 빼는 카운트다운은 다른 창을 오래 보고 있으면
|
||||
// 실제 시간보다 많이 밀린다. 그래서 "언제 끝나야 하는지"(_phaseEndsAt)를 절대 시각으로
|
||||
// 잡아두고, tick이 늦게 와도 그 시각을 기준으로 정확히 계산/전환한다.
|
||||
DateTime? _phaseEndsAt;
|
||||
int completedCycles = 0;
|
||||
bool _phaseTransitioning = false;
|
||||
Timer? _phaseTicker;
|
||||
|
||||
/// 🍅 뽀모도로 진행 상황이 바뀔 때(공부↔휴식 자동 전환) UI에 알림을 띄우기 위한 콜백.
|
||||
/// 자동으로 일어나는 일이라 버튼 액션처럼 (bool,message)를 돌려줄 대상이 없어서 콜백으로 처리한다.
|
||||
void Function(String message)? onPhaseChanged;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isBusy => _isBusy;
|
||||
bool get isRunning => _state == StudyTimerState.running;
|
||||
bool get isPaused => _state == StudyTimerState.paused;
|
||||
bool get isIdle => _state == StudyTimerState.idle;
|
||||
PomodoroPhase? get phase => _phase;
|
||||
|
||||
int get phaseSecondsLeft {
|
||||
if (_phaseEndsAt == null) return 0;
|
||||
final diff = _phaseEndsAt!.difference(DateTime.now()).inSeconds;
|
||||
return diff > 0 ? diff : 0;
|
||||
}
|
||||
|
||||
/// 지금 세션 하나만의 경과 시간(타이머 화면 큰 숫자용). 일시정지 중엔 멈춰 있다.
|
||||
int get liveElapsedSeconds {
|
||||
if (_state != StudyTimerState.running || _lastResumedAt == null) {
|
||||
return _elapsedSeconds;
|
||||
}
|
||||
final diff = DateTime.now().difference(_lastResumedAt!).inSeconds;
|
||||
return _elapsedSeconds + (diff > 0 ? diff : 0);
|
||||
}
|
||||
|
||||
/// 지금 진행 중인 세션까지 포함한 "오늘" 누적 초. running일 때만 실시간으로 늘어난다.
|
||||
int get todaySecondsLive => _todaySeconds + _liveDelta;
|
||||
int get weekSecondsLive => _weekSeconds + _liveDelta;
|
||||
int get totalSecondsLive => _totalSeconds + _liveDelta;
|
||||
|
||||
int get _liveDelta => _state == StudyTimerState.idle ? 0 : liveElapsedSeconds;
|
||||
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
await _loadPomodoroSettings();
|
||||
await _fetchStatus();
|
||||
}
|
||||
|
||||
/// 🐛 [웹 탭 딜레이 버그 수정] 다른 창/탭에 갔다가 이 화면으로 돌아왔을 때 호출한다.
|
||||
/// 백그라운드 탭에서는 브라우저가 타이머를 느리게 돌려서 화면 숫자와 뽀모도로 전환이
|
||||
/// 밀릴 수 있으므로, 서버 상태를 다시 불러오고(진짜 경과 시간 재동기화) 뽀모도로 구간이
|
||||
/// 이미 끝났어야 한다면 바로 따라잡는다.
|
||||
Future<void> onAppResumed() async {
|
||||
await _fetchStatus();
|
||||
checkPhaseDeadlineNow();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_ticker?.cancel();
|
||||
_phaseTicker?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadPomodoroSettings() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
pomodoroEnabled = prefs.getBool(_prefPomodoroEnabled) ?? false;
|
||||
studyMinutes = prefs.getInt(_prefPomodoroStudyMinutes) ?? 50;
|
||||
breakMinutes = prefs.getInt(_prefPomodoroBreakMinutes) ?? 10;
|
||||
} catch (_) {
|
||||
// 조용히 무시 - 기본값(50/10)을 그대로 쓴다.
|
||||
}
|
||||
}
|
||||
|
||||
/// 뽀모도로 켜짐 여부와 공부/휴식 시간(분)을 저장한다. 타이머가 진행 중일 때 값이
|
||||
/// 흔들리면 안 되므로, 화면에서 idle일 때만 호출하도록 막고 있다.
|
||||
Future<void> setPomodoroSettings({
|
||||
required bool enabled,
|
||||
required int studyMinutes,
|
||||
required int breakMinutes,
|
||||
}) async {
|
||||
pomodoroEnabled = enabled;
|
||||
this.studyMinutes = studyMinutes;
|
||||
this.breakMinutes = breakMinutes;
|
||||
_safeNotify();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefPomodoroEnabled, enabled);
|
||||
await prefs.setInt(_prefPomodoroStudyMinutes, studyMinutes);
|
||||
await prefs.setInt(_prefPomodoroBreakMinutes, breakMinutes);
|
||||
} catch (_) {
|
||||
// 조용히 무시 - 이번 세션 동안은 메모리 값으로라도 동작한다.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchStatus() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/study/status?studentId=$studentId'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_state = _parseState(data['state']);
|
||||
_elapsedSeconds = (data['elapsedSeconds'] as num?)?.toInt() ?? 0;
|
||||
_lastResumedAt = data['lastResumedAt'] != null
|
||||
? DateTime.tryParse(data['lastResumedAt'])
|
||||
: null;
|
||||
_todaySeconds = (data['todaySeconds'] as num?)?.toInt() ?? 0;
|
||||
_weekSeconds = (data['weekSeconds'] as num?)?.toInt() ?? 0;
|
||||
_totalSeconds = (data['totalSeconds'] as num?)?.toInt() ?? 0;
|
||||
_restartTickerIfNeeded();
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시 - 마지막으로 알던 상태를 유지한다.
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
StudyTimerState _parseState(dynamic raw) {
|
||||
switch (raw) {
|
||||
case 'running':
|
||||
return StudyTimerState.running;
|
||||
case 'paused':
|
||||
return StudyTimerState.paused;
|
||||
default:
|
||||
return StudyTimerState.idle;
|
||||
}
|
||||
}
|
||||
|
||||
void _restartTickerIfNeeded() {
|
||||
_ticker?.cancel();
|
||||
if (_state == StudyTimerState.running) {
|
||||
_ticker = Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(_) => _safeNotify(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> start() async {
|
||||
final result = await _action(
|
||||
'/api/study/start',
|
||||
body: {"studentId": studentId, "studentName": studentName},
|
||||
onSuccess: (_) async {
|
||||
await _fetchStatus();
|
||||
return '공부를 시작했습니다.';
|
||||
},
|
||||
);
|
||||
if (result.$1 && pomodoroEnabled) {
|
||||
completedCycles = 0;
|
||||
_phase = PomodoroPhase.study;
|
||||
_phaseEndsAt = DateTime.now().add(Duration(minutes: studyMinutes));
|
||||
_startPhaseTicker();
|
||||
_safeNotify();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> pause() async {
|
||||
return _action(
|
||||
'/api/study/pause',
|
||||
onSuccess: (_) async {
|
||||
await _fetchStatus();
|
||||
return '일시정지했습니다.';
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> resume() async {
|
||||
return _action(
|
||||
'/api/study/resume',
|
||||
onSuccess: (_) async {
|
||||
await _fetchStatus();
|
||||
return '다시 시작했습니다.';
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> stop() async {
|
||||
final result = await _action(
|
||||
'/api/study/stop',
|
||||
onSuccess: (result) async {
|
||||
await _fetchStatus(); // 오늘/이번주/전체 합계를 서버 값으로 다시 맞춘다.
|
||||
final minutes =
|
||||
((result['durationSeconds'] as num?)?.toInt() ?? 0) ~/ 60;
|
||||
return '이번 공부 시간: $minutes분 기록 완료!';
|
||||
},
|
||||
);
|
||||
if (result.$1) _stopPhaseTicker();
|
||||
return result;
|
||||
}
|
||||
|
||||
void _stopPhaseTicker() {
|
||||
_phaseTicker?.cancel();
|
||||
_phaseTicker = null;
|
||||
_phase = null;
|
||||
_phaseEndsAt = null;
|
||||
}
|
||||
|
||||
/// 🍅 1초마다 지금이 끝나야 할 시각(_phaseEndsAt)을 지났는지 확인해서, 지났으면
|
||||
/// 공부↔휴식을 자동으로 전환한다. 휴식 구간에서도 계속 흘러야 해서, running일 때만
|
||||
/// 도는 _ticker와는 별도로 관리한다.
|
||||
void _startPhaseTicker() {
|
||||
_phaseTicker?.cancel();
|
||||
_phaseTicker = Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(_) => _checkPhaseDeadline(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 탭이 백그라운드에 있는 동안 브라우저가 setInterval을 느리게 돌리면 위 1초 tick도
|
||||
/// 늦게 온다. 탭이 다시 활성화될 때(앱이 resumed 될 때) 화면에서 이 메서드를 직접
|
||||
/// 호출해서, 밀린 tick을 기다리지 않고 바로 지난 시각을 따라잡는다.
|
||||
void checkPhaseDeadlineNow() => _checkPhaseDeadline();
|
||||
|
||||
void _checkPhaseDeadline() {
|
||||
if (_phase == null || _phaseTransitioning || _phaseEndsAt == null) return;
|
||||
if (DateTime.now().isAfter(_phaseEndsAt!)) {
|
||||
_advancePhase();
|
||||
} else {
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _advancePhase() async {
|
||||
_phaseTransitioning = true;
|
||||
if (_phase == PomodoroPhase.study) {
|
||||
final (success, _) = await pause();
|
||||
if (success) {
|
||||
completedCycles += 1;
|
||||
_phase = PomodoroPhase.breakTime;
|
||||
_phaseEndsAt = DateTime.now().add(Duration(minutes: breakMinutes));
|
||||
onPhaseChanged?.call('공부 끝! $breakMinutes분간 쉬어가요.');
|
||||
} else {
|
||||
_phaseEndsAt = DateTime.now().add(
|
||||
const Duration(seconds: 1),
|
||||
); // 네트워크 문제 등으로 실패했으면 잠시 후 다시 시도.
|
||||
}
|
||||
} else if (_phase == PomodoroPhase.breakTime) {
|
||||
final (success, _) = await resume();
|
||||
if (success) {
|
||||
_phase = PomodoroPhase.study;
|
||||
_phaseEndsAt = DateTime.now().add(Duration(minutes: studyMinutes));
|
||||
onPhaseChanged?.call('휴식 끝! 다시 공부를 시작해요.');
|
||||
} else {
|
||||
_phaseEndsAt = DateTime.now().add(const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
_phaseTransitioning = false;
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> _action(
|
||||
String path, {
|
||||
Map<String, dynamic> body = const {},
|
||||
required Future<String> Function(Map<String, dynamic> result) onSuccess,
|
||||
}) async {
|
||||
_isBusy = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl$path'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"studentId": studentId, ...body}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||
final message = await onSuccess(result);
|
||||
return (true, message);
|
||||
}
|
||||
return (false, '${result['message'] ?? '처리 실패'}');
|
||||
} catch (e) {
|
||||
return (false, '네트워크 에러: $e');
|
||||
} finally {
|
||||
_isBusy = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// 📍 선생님 호출(학생용) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
import '../data/teacher_call_schedule.dart';
|
||||
|
||||
class TeacherCallController extends ChangeNotifier {
|
||||
final String studentId;
|
||||
final String studentName;
|
||||
|
||||
TeacherCallController({required this.studentId, required this.studentName});
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isCalling = false;
|
||||
List<dynamic> _teachers = [];
|
||||
List<dynamic> _myCalls = [];
|
||||
List<dynamic> _ranking = [];
|
||||
bool _disposed = false;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isCalling => _isCalling;
|
||||
List<dynamic> get teachers => _teachers;
|
||||
List<dynamic> get myCalls => _myCalls;
|
||||
List<dynamic> get ranking => _ranking;
|
||||
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool isInClassNow(String teacherName) {
|
||||
final now = DateTime.now();
|
||||
final hhmm =
|
||||
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
|
||||
return teachersInClassNow(now.weekday, hhmm).contains(teacherName);
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
await Future.wait([_fetchTeachers(), _fetchMyCalls(), _fetchRanking()]);
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
Future<void> refresh() => init();
|
||||
|
||||
Future<void> _fetchTeachers() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/teacher-call/teachers'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_teachers = data['teachers'] ?? [];
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시 - 마지막으로 받아온 목록을 유지한다.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchMyCalls() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/teacher-call/my-calls?studentId=$studentId'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_myCalls = data['calls'] ?? [];
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchRanking() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/teacher-call/ranking'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_ranking = data['ranking'] ?? [];
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> callTeacher({
|
||||
required String teacherId,
|
||||
required String purpose,
|
||||
}) async {
|
||||
_isCalling = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/teacher-call/call'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({
|
||||
"studentId": studentId,
|
||||
"studentName": studentName,
|
||||
"teacherId": teacherId,
|
||||
"purpose": purpose,
|
||||
}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||
await Future.wait([_fetchMyCalls(), _fetchRanking()]);
|
||||
return (true, '${result['message'] ?? '호출했습니다.'}');
|
||||
}
|
||||
return (false, '${result['message'] ?? '호출 실패'}');
|
||||
} catch (e) {
|
||||
return (false, '네트워크 에러: $e');
|
||||
} finally {
|
||||
_isCalling = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// 📍 선생님 위치 등록(교사용) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
class TeacherLocationController extends ChangeNotifier {
|
||||
final String teacherId;
|
||||
|
||||
TeacherLocationController({required this.teacherId});
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
String _currentLocation = "교무실";
|
||||
List<dynamic> _receivedCalls = [];
|
||||
bool _disposed = false;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isSaving => _isSaving;
|
||||
String get currentLocation => _currentLocation;
|
||||
List<dynamic> get receivedCalls => _receivedCalls;
|
||||
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
await Future.wait([_fetchMyLocation(), fetchReceivedCalls()]);
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
Future<void> _fetchMyLocation() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/teacher-call/teachers'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
final List<dynamic> list = data['teachers'] ?? [];
|
||||
final mine = list.firstWhere(
|
||||
(t) => t['teacherId'] == teacherId,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (mine != null) _currentLocation = mine['location'] ?? '교무실';
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchReceivedCalls() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse(
|
||||
'$baseUrl/api/teacher-call/received-calls?teacherId=$teacherId',
|
||||
),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_receivedCalls = data['calls'] ?? [];
|
||||
_safeNotify();
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> updateLocation(String location) async {
|
||||
_isSaving = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/teacher-call/location'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"teacherId": teacherId, "location": location}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||
_currentLocation = location;
|
||||
return (true, '${result['message'] ?? '위치가 업데이트되었습니다.'}');
|
||||
}
|
||||
return (false, '${result['message'] ?? '업데이트 실패'}');
|
||||
} catch (e) {
|
||||
return (false, '네트워크 에러: $e');
|
||||
} finally {
|
||||
_isSaving = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// 👥 교사용 학생 계정 관리 화면의 기능(서버 통신/상태/엑셀 일괄등록) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:excel/excel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -37,8 +38,42 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
||||
|
||||
List<dynamic> _students = [];
|
||||
bool _isLoadingStudents = true;
|
||||
String _searchQuery = '';
|
||||
int? _gradeFilter; // null = 전체
|
||||
List<dynamic> get students => _students;
|
||||
bool get isLoadingStudents => _isLoadingStudents;
|
||||
String get searchQuery => _searchQuery;
|
||||
int? get gradeFilter => _gradeFilter;
|
||||
|
||||
/// 🔍 학번 검색 + 학년 필터를 적용하고, 학년 오름차순(1→2→3, 미배정은 맨 뒤)으로
|
||||
/// 정렬한 목록. 화면에는 이 목록을 그린다.
|
||||
List<dynamic> get filteredStudents {
|
||||
final query = _searchQuery.trim();
|
||||
final filtered = _students.where((s) {
|
||||
if (_gradeFilter != null && s['grade'] != _gradeFilter) return false;
|
||||
if (query.isEmpty) return true;
|
||||
return s['id'].toString().contains(query);
|
||||
}).toList();
|
||||
filtered.sort((a, b) {
|
||||
final ga = a['grade'] as int?;
|
||||
final gb = b['grade'] as int?;
|
||||
if (ga == null && gb == null) return 0;
|
||||
if (ga == null) return 1;
|
||||
if (gb == null) return -1;
|
||||
return ga.compareTo(gb);
|
||||
});
|
||||
return filtered;
|
||||
}
|
||||
|
||||
void setSearchQuery(String query) {
|
||||
_searchQuery = query;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setGradeFilter(int? grade) {
|
||||
_gradeFilter = grade;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> init() => fetchStudents();
|
||||
|
||||
@@ -91,7 +126,7 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
||||
/// A열=학번, B열=이름, C열=학년(선택, 숫자) 순서를 기대한다.
|
||||
/// 학번이나 이름이 비어있는 줄은 조용히 무시한다.
|
||||
List<BulkStudentRow> parseExcelBytes(Uint8List bytes) {
|
||||
final excel = Excel.decodeBytes(bytes);
|
||||
final excel = Excel.decodeBytes(_sanitizeXlsxBytes(bytes));
|
||||
if (excel.tables.isEmpty) return [];
|
||||
|
||||
final sheet = excel.tables.values.first;
|
||||
@@ -108,6 +143,70 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// 🩹 [엑셀 파싱 버그 우회] 일부 엑셀 파일(구글시트/WPS 등에서 내보낸 파일 포함)은
|
||||
/// styles.xml에 커스텀 숫자서식 id를 164 미만(예: 0)으로 잘못 적어두는데, excel
|
||||
/// 패키지가 이걸 엄격하게 걸러내며 예외를 던져서 파일 전체를 못 읽게 만든다.
|
||||
/// 우리는 셀 값(학번/이름/학년)만 필요하고 서식은 필요 없으니, zip 안의 styles.xml만
|
||||
/// 열어서 그 잘못된 커스텀 서식 선언을 지우고 다시 압축해서 넘긴다.
|
||||
Uint8List _sanitizeXlsxBytes(Uint8List bytes) {
|
||||
try {
|
||||
final zip = ZipDecoder().decodeBytes(bytes);
|
||||
final stylesFile = zip.files.cast<ArchiveFile?>().firstWhere(
|
||||
(f) => f?.name == 'xl/styles.xml',
|
||||
orElse: () => null,
|
||||
);
|
||||
if (stylesFile == null) return bytes;
|
||||
|
||||
final xml = utf8.decode(stylesFile.content as List<int>);
|
||||
final fixedXml = _stripInvalidCustomNumFmts(xml);
|
||||
if (fixedXml == xml) return bytes; // 고칠 게 없었으면 원본 그대로.
|
||||
|
||||
final newZip = Archive();
|
||||
for (final file in zip.files) {
|
||||
if (!file.isFile) continue;
|
||||
if (file.name == 'xl/styles.xml') {
|
||||
final content = utf8.encode(fixedXml);
|
||||
newZip.addFile(ArchiveFile('xl/styles.xml', content.length, content));
|
||||
} else {
|
||||
newZip.addFile(ArchiveFile(file.name, file.size, file.content));
|
||||
}
|
||||
}
|
||||
final rezipped = ZipEncoder().encode(newZip);
|
||||
return rezipped != null ? Uint8List.fromList(rezipped) : bytes;
|
||||
} catch (_) {
|
||||
return bytes; // 손대다 실패하면 원본 그대로 시도 (기존 동작 유지).
|
||||
}
|
||||
}
|
||||
|
||||
/// styles.xml 문자열에서 `<numFmt numFmtId="N" .../>` 태그 중 N이 164 미만인
|
||||
/// (규격 위반인) 것만 통째로 제거한다. 그 서식을 참조하던 셀은 기본 서식으로 처리될
|
||||
/// 뿐이라 값 읽기에는 영향이 없다.
|
||||
String _stripInvalidCustomNumFmts(String xml) {
|
||||
final buffer = StringBuffer();
|
||||
int i = 0;
|
||||
while (true) {
|
||||
final tagStart = xml.indexOf('<numFmt ', i);
|
||||
if (tagStart == -1) {
|
||||
buffer.write(xml.substring(i));
|
||||
break;
|
||||
}
|
||||
final tagEnd = xml.indexOf('/>', tagStart);
|
||||
if (tagEnd == -1) {
|
||||
buffer.write(xml.substring(i));
|
||||
break;
|
||||
}
|
||||
buffer.write(xml.substring(i, tagStart));
|
||||
final tag = xml.substring(tagStart, tagEnd + 2);
|
||||
final idMatch = RegExp(r'numFmtId="(\d+)"').firstMatch(tag);
|
||||
final id = idMatch != null ? int.tryParse(idMatch.group(1)!) : null;
|
||||
if (id == null || id >= 164) {
|
||||
buffer.write(tag); // 정상 범위의 커스텀 서식은 그대로 둔다.
|
||||
}
|
||||
i = tagEnd + 2;
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String? _cellText(CellValue? value) {
|
||||
if (value == null) return null;
|
||||
String text;
|
||||
|
||||
@@ -22,6 +22,18 @@ void main() async {
|
||||
runApp(const SchoolAttendanceApp());
|
||||
}
|
||||
|
||||
// 🌊 스크롤이 끝에 닿을 때 나오는 파란색 오버스크롤 글로우 효과를 없앤다.
|
||||
class _NoGlowScrollBehavior extends MaterialScrollBehavior {
|
||||
@override
|
||||
Widget buildOverscrollIndicator(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
ScrollableDetails details,
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
class SchoolAttendanceApp extends StatelessWidget {
|
||||
const SchoolAttendanceApp({super.key});
|
||||
|
||||
@@ -30,6 +42,7 @@ class SchoolAttendanceApp extends StatelessWidget {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: '$schoolName 학생 도우미',
|
||||
scrollBehavior: _NoGlowScrollBehavior(),
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
scaffoldBackgroundColor: AppPalette.mist,
|
||||
@@ -94,6 +107,7 @@ class _StartupGateState extends State<_StartupGate> {
|
||||
userName: session.userName,
|
||||
role: session.role,
|
||||
isDeviceMatched: session.isDeviceMatched,
|
||||
grade: session.grade,
|
||||
);
|
||||
}
|
||||
return const LoginScreen();
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../ui/app_notice.dart';
|
||||
import '../ui/hold_to_reveal_password_field.dart';
|
||||
import '../ui/login_screen.dart';
|
||||
|
||||
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
|
||||
@@ -22,9 +24,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
||||
Future<void> _updatePassword() async {
|
||||
String newPw = _pwController.text.trim();
|
||||
if (newPw.isEmpty || newPw == "1234") {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('초기 비밀번호와 다른 안전한 비밀번호를 입력하세요.')),
|
||||
);
|
||||
AppNotice.show(context, '초기 비밀번호와 다른 안전한 비밀번호를 입력하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,9 +38,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
||||
|
||||
final res = jsonDecode(response.body);
|
||||
if (response.statusCode == 200 && res['status'] == 'success') {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('비밀번호 변경 완료! 다시 로그인해 주세요.')),
|
||||
);
|
||||
AppNotice.show(context, '비밀번호 변경 완료! 다시 로그인해 주세요.');
|
||||
// 비밀번호를 바꿨으니 다시 로그인 화면으로 강제 이동
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
@@ -48,9 +46,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('통신 실패')));
|
||||
AppNotice.show(context, '통신 실패');
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
@@ -80,13 +76,12 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
HoldToRevealPasswordField(
|
||||
controller: _pwController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
autofocus: true,
|
||||
labelText: '새로운 비밀번호 입력',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _updatePassword(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
|
||||
@@ -1,14 +1,45 @@
|
||||
// 🎨 앱 전역 무채색 팔레트 (coolors.co: 1c1c1c-daddd8-ecebe4-eef0f2-fafaff).
|
||||
// 배경/카드/배너 같은 구조적 색상은 전부 이 5색으로 통일한다.
|
||||
// 삭제/위반/거절처럼 사용자가 즉시 알아채야 하는 경고성 색상(빨강 등)만 예외로 유지한다.
|
||||
//
|
||||
// 🛍️ mist/paper/linen/sage(구조색 4종)는 프로필 꾸미기 템플릿을 장착하면 화면
|
||||
// 전체에 그 테마가 번지도록 const를 빼고 바꿔치기 가능하게 했다. ink는 텍스트/
|
||||
// 버튼 등 너무 많은 곳에서 쓰여서 바꾸면 가독성이 깨질 수 있어 그대로 고정 둔다.
|
||||
// applyTemplateTheme()로만 바꾸고, 직접 대입하지 않는다.
|
||||
import 'package:flutter/material.dart';
|
||||
import 'profile_themes.dart';
|
||||
|
||||
class AppPalette {
|
||||
AppPalette._();
|
||||
|
||||
static const Color ink = Color(0xFF1C1C1C); // 가장 어두운 톤 — 배너/주요 텍스트/버튼
|
||||
static const Color sage = Color(0xFFDADDD8); // 보더/구분선/보조 표면
|
||||
static const Color linen = Color(0xFFECEBE4); // 은은한 카드/배지 배경
|
||||
static const Color mist = Color(0xFFEEF0F2); // 페이지 배경
|
||||
static const Color paper = Color(0xFFFAFAFF); // 카드 표면(거의 흰색)
|
||||
|
||||
static const Color _defaultSage = Color(0xFFDADDD8);
|
||||
static const Color _defaultLinen = Color(0xFFECEBE4);
|
||||
static const Color _defaultMist = Color(0xFFEEF0F2);
|
||||
static const Color _defaultPaper = Color(0xFFFAFAFF);
|
||||
|
||||
static Color sage = _defaultSage; // 보더/구분선/보조 표면
|
||||
static Color linen = _defaultLinen; // 은은한 카드/배지 배경
|
||||
static Color mist = _defaultMist; // 페이지 배경
|
||||
static Color paper = _defaultPaper; // 카드 표면(거의 흰색)
|
||||
|
||||
/// 🛍️ 장착한 템플릿에 맞춰 구조색 4종을 한 번에 바꾼다. null을 주면 기본값으로 복원.
|
||||
static void applyTemplateTheme(ProfileTheme? theme) {
|
||||
mist = theme?.background ?? _defaultMist;
|
||||
paper = theme?.surface ?? _defaultPaper;
|
||||
linen = theme?.surfaceAlt ?? _defaultLinen;
|
||||
sage = theme?.border ?? _defaultSage;
|
||||
}
|
||||
|
||||
// 🚨 경고성 색상 3종 - 예전엔 화면마다 Colors.red/red[400]/red[700]/redAccent처럼
|
||||
// 제각각 써서 같은 의미인데 색이 미묘하게 달랐다. 이제 의미별로 딱 하나씩만 쓴다.
|
||||
static const Color danger = Color(0xFFE13B3B); // 삭제/위반/거절/신고 등 위험/경고
|
||||
static const Color dangerBg = Color(0xFFFCE9E9); // danger의 연한 배경(배지 등)
|
||||
static const Color warning = Color(0xFFF08A24); // 대기중/수업중/진행중 등 주의
|
||||
static const Color warningBg = Color(0xFFFDF0E1); // warning의 연한 배경
|
||||
static const Color success = Color(0xFF2E9E5B); // 완료/성공(NFC 스캔 성공 등)
|
||||
static const Color successBg = Color(0xFFE4F5EB); // success의 연한 배경
|
||||
static const Color approved = Color(0xFF3B78C2); // 승인됨/완료 상태 표시
|
||||
static const Color approvedBg = Color(0xFFE7F0FA); // approved의 연한 배경
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// 🎨 프로필 꾸미기 "템플릿"을 장착하면 프로필 카드 색뿐 아니라 화면 전체의
|
||||
// 배경/타일/아이콘 색까지 통째로 바뀌는 "테마" 데이터.
|
||||
// 템플릿은 상점에서 사는 색/애니메이션 상품, 테마는 그 템플릿이 몰고 오는
|
||||
// 화면 전체 디자인(배경색/타일색/아이콘색/모양)을 가리킨다 - 용어 구분은
|
||||
// 사장님이 정해준 것을 그대로 따른다.
|
||||
//
|
||||
// 템플릿 하나하나에 대해 직접 디자인한 고정 팔레트라서, 서버에서 내려주는
|
||||
// code(예: "lavender")로 이 맵을 찾아 쓴다. 템플릿이 늘어나면 여기도 추가해야 한다.
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ProfileTheme {
|
||||
final Color primary; // 프로필 카드 그라데이션 시작색 (템플릿 색)
|
||||
final Color secondary; // 프로필 카드 그라데이션 끝색 (템플릿 색)
|
||||
final Color background; // 페이지 배경
|
||||
final Color surface; // 타일/카드 배경
|
||||
final Color surfaceAlt; // 아이콘 동그라미 등 보조 표면
|
||||
final Color border; // 타일 테두리/구분선
|
||||
final Color accent; // 타일 아이콘 등 강조색
|
||||
final Color onBackground; // 배경/타일 위에 올라가는 기본 텍스트 색
|
||||
final bool isDark;
|
||||
|
||||
const ProfileTheme({
|
||||
required this.primary,
|
||||
required this.secondary,
|
||||
required this.background,
|
||||
required this.surface,
|
||||
required this.surfaceAlt,
|
||||
required this.border,
|
||||
required this.accent,
|
||||
required this.onBackground,
|
||||
this.isDark = false,
|
||||
});
|
||||
}
|
||||
|
||||
const Map<String, ProfileTheme> profileThemes = {
|
||||
'lavender': ProfileTheme(
|
||||
primary: Color(0xFFB39DDB),
|
||||
secondary: Color(0xFF7E57C2),
|
||||
background: Color(0xFFF5F0FA),
|
||||
surface: Color(0xFFFFFFFF),
|
||||
surfaceAlt: Color(0xFFEDE4F7),
|
||||
border: Color(0xFFDCCCEF),
|
||||
accent: Color(0xFF7E57C2),
|
||||
onBackground: Color(0xFF1C1C1C),
|
||||
),
|
||||
'mint': ProfileTheme(
|
||||
primary: Color(0xFF80CBC4),
|
||||
secondary: Color(0xFF00897B),
|
||||
background: Color(0xFFEAF6F4),
|
||||
surface: Color(0xFFFFFFFF),
|
||||
surfaceAlt: Color(0xFFDBF0EC),
|
||||
border: Color(0xFFBFE3DC),
|
||||
accent: Color(0xFF00897B),
|
||||
onBackground: Color(0xFF1C1C1C),
|
||||
),
|
||||
'sunset': ProfileTheme(
|
||||
primary: Color(0xFFFFB74D),
|
||||
secondary: Color(0xFFE64A19),
|
||||
background: Color(0xFFFFF3E6),
|
||||
surface: Color(0xFFFFFFFF),
|
||||
surfaceAlt: Color(0xFFFFE5CC),
|
||||
border: Color(0xFFFFD1A6),
|
||||
accent: Color(0xFFE64A19),
|
||||
onBackground: Color(0xFF1C1C1C),
|
||||
),
|
||||
'ocean': ProfileTheme(
|
||||
primary: Color(0xFF4FC3F7),
|
||||
secondary: Color(0xFF01579B),
|
||||
background: Color(0xFFEAF6FC),
|
||||
surface: Color(0xFFFFFFFF),
|
||||
surfaceAlt: Color(0xFFD9EEF9),
|
||||
border: Color(0xFFBBDDF0),
|
||||
accent: Color(0xFF01579B),
|
||||
onBackground: Color(0xFF1C1C1C),
|
||||
),
|
||||
'aurora': ProfileTheme(
|
||||
primary: Color(0xFF81C784),
|
||||
secondary: Color(0xFF1565C0),
|
||||
background: Color(0xFFEDF6EF),
|
||||
surface: Color(0xFFFFFFFF),
|
||||
surfaceAlt: Color(0xFFDCEEE0),
|
||||
border: Color(0xFFC3E2CA),
|
||||
accent: Color(0xFF1565C0),
|
||||
onBackground: Color(0xFF1C1C1C),
|
||||
),
|
||||
// 🌌 최고가 템플릿 - 다른 5개와 달리 통째로 다크 테마라서 들어가자마자 확 다르게 느껴진다.
|
||||
'galaxy': ProfileTheme(
|
||||
primary: Color(0xFF9575CD),
|
||||
secondary: Color(0xFF1A237E),
|
||||
background: Color(0xFF14122B),
|
||||
surface: Color(0xFF211F42),
|
||||
surfaceAlt: Color(0xFF2C2A52),
|
||||
border: Color(0xFF3A3768),
|
||||
accent: Color(0xFFB39DDB),
|
||||
onBackground: Color(0xFFF5F3FF),
|
||||
isDark: true,
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// 🌐 지금 장착된 프로필 테마를 앱 전체에 퍼뜨리는 통로.
|
||||
// AppPalette의 구조색(mist/paper/linen/sage)은 전역 static 값이라 바꾸면 되지만,
|
||||
// TitlePill처럼 "실시간으로" 다시 그려져야 하는 공유 위젯은 구독할 대상이
|
||||
// 따로 필요해서 ValueNotifier를 하나 더 둔다. 템플릿을 장착/해제할 때는
|
||||
// AppPalette를 직접 건드리지 말고 꼭 이 함수를 통해서만 바꾼다.
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'app_palette.dart';
|
||||
import 'profile_themes.dart';
|
||||
|
||||
final ValueNotifier<ProfileTheme?> globalProfileTheme = ValueNotifier(null);
|
||||
|
||||
void applyGlobalProfileTheme(ProfileTheme? theme) {
|
||||
AppPalette.applyTemplateTheme(theme);
|
||||
globalProfileTheme.value = theme;
|
||||
}
|
||||
+29
-13
@@ -3,7 +3,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/admin_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'login_screen.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
// ==========================================
|
||||
// 🛠️ 5. 관리자 대시보드 (DB 초기화 암호 파라미터 보정 완료)
|
||||
@@ -27,9 +29,7 @@ class _AdminDashboardState extends State<AdminDashboard> {
|
||||
Future<void> _resetDatabase() async {
|
||||
final message = await _controller.resetDatabase();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
|
||||
void _showResetConfirmDialog() {
|
||||
@@ -39,7 +39,10 @@ class _AdminDashboardState extends State<AdminDashboard> {
|
||||
return AlertDialog(
|
||||
title: const Text(
|
||||
'DB 초기화 경고',
|
||||
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
color: AppPalette.danger,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
content: const Text(
|
||||
'모든 학생의 출석 및 휴대폰 제출 데이터가 영구적으로 삭제됩니다.\n\n정말 초기화하시겠습니까?',
|
||||
@@ -50,7 +53,10 @@ class _AdminDashboardState extends State<AdminDashboard> {
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||
autofocus: true,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppPalette.danger,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext);
|
||||
_resetDatabase();
|
||||
@@ -74,9 +80,11 @@ class _AdminDashboardState extends State<AdminDashboard> {
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
appBar: AppBar(
|
||||
title: const Text('관리자 시스템'),
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const TitlePill('관리자 시스템'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
@@ -90,6 +98,8 @@ class _AdminDashboardState extends State<AdminDashboard> {
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -101,21 +111,26 @@ class _AdminDashboardState extends State<AdminDashboard> {
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'데이터베이스 관리',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
_controller.isLoading
|
||||
? const CircularProgressIndicator(color: Colors.red)
|
||||
? const CircularProgressIndicator(
|
||||
color: AppPalette.danger,
|
||||
)
|
||||
: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 60,
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red[50],
|
||||
foregroundColor: Colors.red,
|
||||
backgroundColor: AppPalette.dangerBg,
|
||||
foregroundColor: AppPalette.danger,
|
||||
side: const BorderSide(
|
||||
color: Colors.red,
|
||||
color: AppPalette.danger,
|
||||
width: 2,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -137,6 +152,7 @@ class _AdminDashboardState extends State<AdminDashboard> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
+14
-5
@@ -9,8 +9,13 @@ class AppNotice {
|
||||
static final List<_QueuedNotice> _queue = [];
|
||||
static bool _showing = false;
|
||||
|
||||
static void show(BuildContext context, String message, {IconData? icon}) {
|
||||
_queue.add(_QueuedNotice(context, message, icon));
|
||||
static void show(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
IconData? icon,
|
||||
Color? color,
|
||||
}) {
|
||||
_queue.add(_QueuedNotice(context, message, icon, color));
|
||||
_tryShowNext();
|
||||
}
|
||||
|
||||
@@ -28,6 +33,7 @@ class AppNotice {
|
||||
builder: (context) => _NoticeBanner(
|
||||
message: next.message,
|
||||
icon: next.icon,
|
||||
color: next.color,
|
||||
onDone: () {
|
||||
entry.remove();
|
||||
_showing = false;
|
||||
@@ -43,17 +49,20 @@ class _QueuedNotice {
|
||||
final BuildContext context;
|
||||
final String message;
|
||||
final IconData? icon;
|
||||
_QueuedNotice(this.context, this.message, this.icon);
|
||||
final Color? color;
|
||||
_QueuedNotice(this.context, this.message, this.icon, this.color);
|
||||
}
|
||||
|
||||
class _NoticeBanner extends StatefulWidget {
|
||||
final String message;
|
||||
final IconData? icon;
|
||||
final Color? color;
|
||||
final VoidCallback onDone;
|
||||
|
||||
const _NoticeBanner({
|
||||
required this.message,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onDone,
|
||||
});
|
||||
|
||||
@@ -102,7 +111,7 @@ class _NoticeBannerState extends State<_NoticeBanner>
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.ink,
|
||||
color: widget.color ?? AppPalette.ink,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
@@ -122,7 +131,7 @@ class _NoticeBannerState extends State<_NoticeBanner>
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.message,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
color: AppPalette.paper,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
// 🗣️ 커뮤니티 게시판(백판) 홈 화면 (UI 전용). 카테고리 탭 + 글 목록 + 글쓰기.
|
||||
// 서버 통신/상태는 lib/function/board_list_controller.dart, board_write_controller.dart가 담당한다.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
import '../function/board_list_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../theme/theme_sync.dart';
|
||||
import 'board_post_detail_screen.dart';
|
||||
import 'board_report_screen.dart';
|
||||
import 'board_write_screen.dart';
|
||||
import 'launchpad_transition.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class BoardHomeScreen extends StatefulWidget {
|
||||
final String studentId;
|
||||
final bool canModerate;
|
||||
|
||||
const BoardHomeScreen({
|
||||
super.key,
|
||||
required this.studentId,
|
||||
this.canModerate = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BoardHomeScreen> createState() => _BoardHomeScreenState();
|
||||
}
|
||||
|
||||
class _BoardHomeScreenState extends State<BoardHomeScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
List<dynamic> _categories = [];
|
||||
bool _isLoadingCategories = true;
|
||||
TabController? _tabController;
|
||||
final Map<String, BoardListController> _controllers = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchCategories();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController?.dispose();
|
||||
for (final c in _controllers.values) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetchCategories() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/board/categories'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_categories = data['categories'] ?? [];
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시 - 아래에서 목록이 비어있으면 에러로 처리한다.
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingCategories = false;
|
||||
if (_categories.isNotEmpty) {
|
||||
_tabController = TabController(
|
||||
length: _categories.length,
|
||||
vsync: this,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BoardListController _controllerFor(String category) {
|
||||
return _controllers.putIfAbsent(
|
||||
category,
|
||||
() =>
|
||||
BoardListController(category: category, studentId: widget.studentId)
|
||||
..init(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openWrite() async {
|
||||
final tabController = _tabController;
|
||||
if (tabController == null) return;
|
||||
final category = _categories[tabController.index];
|
||||
final changed = await pushLaunchpad<bool>(
|
||||
context,
|
||||
(context) => BoardWriteScreen(
|
||||
studentId: widget.studentId,
|
||||
category: category['key'],
|
||||
categoryName: category['name'],
|
||||
),
|
||||
);
|
||||
if (changed == true) {
|
||||
_controllerFor(category['key']).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
void _openModeration() {
|
||||
pushLaunchpad(
|
||||
context,
|
||||
(context) => BoardReportScreen(reviewerId: widget.studentId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openPost(int postId, String category) async {
|
||||
final changed = await pushLaunchpad<bool>(
|
||||
context,
|
||||
(context) =>
|
||||
BoardPostDetailScreen(postId: postId, studentId: widget.studentId),
|
||||
);
|
||||
if (changed == true) {
|
||||
_controllerFor(category).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const TitlePill('백판'),
|
||||
actions: [
|
||||
if (widget.canModerate)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.shield_outlined),
|
||||
tooltip: '신고 검토',
|
||||
onPressed: _openModeration,
|
||||
),
|
||||
],
|
||||
bottom: _tabController == null
|
||||
? null
|
||||
: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppPalette.ink,
|
||||
unselectedLabelColor: Colors.grey,
|
||||
indicatorColor: AppPalette.ink,
|
||||
tabs: [for (final c in _categories) Tab(text: c['name'])],
|
||||
),
|
||||
),
|
||||
floatingActionButton: _tabController == null
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
backgroundColor:
|
||||
globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
onPressed: _openWrite,
|
||||
child: const Icon(Icons.edit_rounded),
|
||||
),
|
||||
body: _isLoadingCategories
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _tabController == null
|
||||
? Center(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _isLoadingCategories = true);
|
||||
_fetchCategories();
|
||||
},
|
||||
child: const Text('게시판을 불러오지 못했습니다. 다시 시도'),
|
||||
),
|
||||
)
|
||||
: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
for (final c in _categories)
|
||||
_CategoryBoardList(
|
||||
controller: _controllerFor(c['key']),
|
||||
onOpenPost: (postId) => _openPost(postId, c['key']),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryBoardList extends StatefulWidget {
|
||||
final BoardListController controller;
|
||||
final ValueChanged<int> onOpenPost;
|
||||
|
||||
const _CategoryBoardList({
|
||||
required this.controller,
|
||||
required this.onOpenPost,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_CategoryBoardList> createState() => _CategoryBoardListState();
|
||||
}
|
||||
|
||||
class _CategoryBoardListState extends State<_CategoryBoardList> {
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
widget.controller.loadMore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: widget.controller,
|
||||
builder: (context, _) {
|
||||
if (widget.controller.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final posts = widget.controller.posts;
|
||||
if (posts.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'아직 글이 없어요.\n첫 글을 남겨보세요.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: widget.controller.refresh,
|
||||
child: const Text('새로고침'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: widget.controller.refresh,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
itemCount: posts.length + (widget.controller.hasMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= posts.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final post = posts[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppPalette.sage),
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
post['title'] ?? '',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${post['createdAt'] ?? ''}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.mode_comment_outlined,
|
||||
size: 15,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text('${post['commentCount'] ?? 0}'),
|
||||
],
|
||||
),
|
||||
onTap: () => widget.onOpenPost(post['id'] as int),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
// 💬 커뮤니티 게시판 - 게시글 상세 + 댓글 화면 (UI 전용).
|
||||
// 서버 통신/상태는 lib/function/board_post_controller.dart가 담당한다.
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/board_post_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../theme/theme_sync.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class BoardPostDetailScreen extends StatefulWidget {
|
||||
final int postId;
|
||||
final String studentId;
|
||||
|
||||
const BoardPostDetailScreen({
|
||||
super.key,
|
||||
required this.postId,
|
||||
required this.studentId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BoardPostDetailScreen> createState() => _BoardPostDetailScreenState();
|
||||
}
|
||||
|
||||
class _BoardPostDetailScreenState extends State<BoardPostDetailScreen> {
|
||||
late final BoardPostController _controller;
|
||||
final _commentController = TextEditingController();
|
||||
bool _changed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = BoardPostController(
|
||||
postId: widget.postId,
|
||||
studentId: widget.studentId,
|
||||
);
|
||||
_controller.init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_commentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submitComment() async {
|
||||
final text = _commentController.text;
|
||||
final (success, message) = await _controller.addComment(text);
|
||||
if (!mounted) return;
|
||||
if (success) {
|
||||
_commentController.clear();
|
||||
_changed = true;
|
||||
}
|
||||
AppNotice.show(
|
||||
context,
|
||||
message,
|
||||
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showReportSheet({
|
||||
required String targetType,
|
||||
required int targetId,
|
||||
}) async {
|
||||
final reasonController = TextEditingController();
|
||||
final reason = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppPalette.paper,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (sheetContext) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'신고 사유 (선택)',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: reasonController,
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
hintText: '어떤 점이 문제인지 알려주세요.',
|
||||
filled: true,
|
||||
fillColor: AppPalette.mist,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
onPressed: () =>
|
||||
Navigator.of(sheetContext).pop(reasonController.text),
|
||||
child: const Text('신고하기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (reason == null || !mounted) return;
|
||||
final (success, message) = await _controller.report(
|
||||
targetType: targetType,
|
||||
targetId: targetId,
|
||||
reason: reason.trim().isEmpty ? null : reason.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(
|
||||
context,
|
||||
message,
|
||||
icon: success ? Icons.flag_rounded : Icons.error_outline_rounded,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deletePost() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('삭제', style: TextStyle(color: AppPalette.danger)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
final (success, message) = await _controller.deletePost();
|
||||
if (!mounted) return;
|
||||
AppNotice.show(context, message);
|
||||
if (success) Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
Future<void> _deleteComment(int commentId) async {
|
||||
final (success, message) = await _controller.deleteComment(commentId);
|
||||
if (!mounted) return;
|
||||
if (success) _changed = true;
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop) Navigator.of(context).pop(_changed);
|
||||
},
|
||||
child: ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) {
|
||||
final post = _controller.post;
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const TitlePill('게시글'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 18),
|
||||
onPressed: () => Navigator.of(context).pop(_changed),
|
||||
),
|
||||
actions: [
|
||||
if (post != null && post['isMine'] != true)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.flag_outlined),
|
||||
tooltip: '신고',
|
||||
onPressed: () => _showReportSheet(
|
||||
targetType: 'post',
|
||||
targetId: widget.postId,
|
||||
),
|
||||
),
|
||||
if (post != null && post['isMine'] == true)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
tooltip: '삭제',
|
||||
onPressed: _deletePost,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _controller.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _controller.postDeleted || post == null
|
||||
? const Center(
|
||||
child: Text(
|
||||
'삭제되었거나 존재하지 않는 게시글입니다.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildPostCard(post),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'댓글 ${_controller.comments.length}개',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._controller.comments.map(_buildCommentTile),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildCommentComposer(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPostCard(Map<String, dynamic> post) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppPalette.sage),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
post['title'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
color: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
post['isMine'] == true ? '글쓴이(나)' : '익명',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12.5),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${post['createdAt'] ?? ''}',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12.5),
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.visibility_outlined, size: 14, color: Colors.grey),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${post['viewCount'] ?? 0}',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
Text(
|
||||
post['content'] ?? '',
|
||||
style: const TextStyle(fontSize: 15, height: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommentTile(dynamic comment) {
|
||||
final bool isMine = comment['isMine'] == true;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'${comment['anonLabel'] ?? '익명'}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12.5,
|
||||
color: isMine ? AppPalette.ink : Colors.grey[700],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${comment['createdAt'] ?? ''}',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 11.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
comment['content'] ?? '',
|
||||
style: const TextStyle(fontSize: 14, height: 1.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: Icon(
|
||||
isMine ? Icons.delete_outline_rounded : Icons.flag_outlined,
|
||||
size: 18,
|
||||
color: Colors.grey,
|
||||
),
|
||||
onPressed: isMine
|
||||
? () => _deleteComment(comment['id'] as int)
|
||||
: () => _showReportSheet(
|
||||
targetType: 'comment',
|
||||
targetId: comment['id'] as int,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommentComposer() {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _commentController,
|
||||
decoration: InputDecoration(
|
||||
hintText: '익명으로 댓글 달기',
|
||||
filled: true,
|
||||
fillColor: AppPalette.paper,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_controller.isBusy
|
||||
? const SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.send_rounded, color: AppPalette.ink),
|
||||
onPressed: _submitComment,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// 🚩 커뮤니티 게시판 - 신고 검토(운영자용) 화면 (UI 전용).
|
||||
// 서버 통신/상태는 lib/function/board_report_controller.dart가 담당한다.
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/board_report_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class BoardReportScreen extends StatefulWidget {
|
||||
final String reviewerId;
|
||||
|
||||
const BoardReportScreen({super.key, required this.reviewerId});
|
||||
|
||||
@override
|
||||
State<BoardReportScreen> createState() => _BoardReportScreenState();
|
||||
}
|
||||
|
||||
class _BoardReportScreenState extends State<BoardReportScreen> {
|
||||
late final BoardReportController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = BoardReportController(reviewerId: widget.reviewerId);
|
||||
_controller.init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _resolve(int reportId, String action) async {
|
||||
final (success, message) = await _controller.resolve(reportId, action);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) {
|
||||
final reports = _controller.reports;
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const TitlePill('게시판 신고 검토'),
|
||||
),
|
||||
body: _controller.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: reports.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'처리할 신고가 없습니다.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _controller.fetchPending,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: reports.length,
|
||||
itemBuilder: (context, index) {
|
||||
final report = reports[index];
|
||||
final reportId = report['id'] as int;
|
||||
final alreadyDeleted = report['alreadyDeleted'] == true;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppPalette.sage),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Chip(
|
||||
label: Text(
|
||||
report['targetType'] == 'post'
|
||||
? '게시글'
|
||||
: '댓글',
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
backgroundColor: AppPalette.mist,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${report['createdAt'] ?? ''}',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'${report['targetContent'] ?? ''}',
|
||||
style: const TextStyle(fontSize: 14, height: 1.4),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (report['reason'] != null &&
|
||||
'${report['reason']}'.isNotEmpty)
|
||||
Text(
|
||||
'신고 사유: ${report['reason']}',
|
||||
style: const TextStyle(
|
||||
color: AppPalette.danger,
|
||||
fontSize: 12.5,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'신고자 학번: ${report['reporterStudentId'] ?? ''}',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (alreadyDeleted)
|
||||
const Text(
|
||||
'이미 삭제된 항목입니다.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _controller.isBusy
|
||||
? null
|
||||
: () => _resolve(reportId, 'dismiss'),
|
||||
child: const Text('유지 (문제 없음)'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppPalette.danger,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: _controller.isBusy
|
||||
? null
|
||||
: () => _resolve(reportId, 'delete'),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// ✍️ 커뮤니티 게시판 - 글쓰기 화면 (UI 전용).
|
||||
// 서버 통신/상태는 lib/function/board_write_controller.dart가 담당한다.
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/board_write_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class BoardWriteScreen extends StatefulWidget {
|
||||
final String studentId;
|
||||
final String category;
|
||||
final String categoryName;
|
||||
|
||||
const BoardWriteScreen({
|
||||
super.key,
|
||||
required this.studentId,
|
||||
required this.category,
|
||||
required this.categoryName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BoardWriteScreen> createState() => _BoardWriteScreenState();
|
||||
}
|
||||
|
||||
class _BoardWriteScreenState extends State<BoardWriteScreen> {
|
||||
late final BoardWriteController _controller;
|
||||
final _titleController = TextEditingController();
|
||||
final _contentController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = BoardWriteController(studentId: widget.studentId);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_titleController.dispose();
|
||||
_contentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final (success, message) = await _controller.submit(
|
||||
category: widget.category,
|
||||
title: _titleController.text,
|
||||
content: _contentController.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(
|
||||
context,
|
||||
message,
|
||||
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||
);
|
||||
if (success) Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
@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,
|
||||
centerTitle: true,
|
||||
title: TitlePill('${widget.categoryName} 글쓰기'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _controller.isSubmitting ? null : _submit,
|
||||
child: _controller.isSubmitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text(
|
||||
'등록',
|
||||
style: TextStyle(
|
||||
color: AppPalette.ink,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 4, left: 4),
|
||||
child: Text(
|
||||
'글쓴이는 익명으로 표시됩니다.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
maxLength: 100,
|
||||
decoration: InputDecoration(
|
||||
hintText: '제목',
|
||||
filled: true,
|
||||
fillColor: AppPalette.paper,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: AppPalette.sage),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: _contentController,
|
||||
maxLines: 12,
|
||||
minLines: 8,
|
||||
decoration: InputDecoration(
|
||||
hintText: '내용을 입력하세요',
|
||||
filled: true,
|
||||
fillColor: AppPalette.paper,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: AppPalette.sage),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/dashboard_tile_layout.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class DashboardSettingsPage extends StatelessWidget {
|
||||
const DashboardSettingsPage({super.key});
|
||||
@@ -12,10 +13,11 @@ class DashboardSettingsPage extends StatelessWidget {
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
appBar: AppBar(
|
||||
title: const Text('설정'),
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const TitlePill('설정'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/device_checkout_ledger_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../theme/theme_sync.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class DeviceCheckoutLedgerPage extends StatefulWidget {
|
||||
final String? teacherId;
|
||||
@@ -38,38 +41,204 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
|
||||
teacherName: widget.teacherName ?? '간편인증 선생',
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
|
||||
Future<void> _reject(int id) async {
|
||||
final (_, message) = await _controller.reject(id);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
|
||||
// 🙌 [전체 학생 허용] 한 명씩 누르기 귀찮을 때, 대기 중인 요청을 한 번에 전부 승인한다.
|
||||
// 1시간 59분 넘는 장시간 요청은 자동으로 제외되어 대기중으로 남는다 - 따로 검토가 필요해서.
|
||||
void _showApproveAllConfirmDialog() {
|
||||
final pendingCount = _controller.requests
|
||||
.where((r) => r['status'] == 'PENDING')
|
||||
.length;
|
||||
if (pendingCount == 0) {
|
||||
AppNotice.show(context, '승인 대기 중인 요청이 없습니다.');
|
||||
return;
|
||||
}
|
||||
final approvableCount = _controller.autoApprovableIds.length;
|
||||
final excludedCount = pendingCount - approvableCount;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('전체 학생 허용'),
|
||||
content: Text(
|
||||
excludedCount > 0
|
||||
? '대기 중인 요청 $pendingCount건 중 $approvableCount건을 승인합니다.\n'
|
||||
'장시간(1시간 59분 이상) 요청 $excludedCount건은 제외되어 "많은 시간요청 학생"에서 따로 확인해야 합니다.'
|
||||
: '대기 중인 요청 $pendingCount건을 한 번에 전부 승인하시겠습니까?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
autofocus: true,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
),
|
||||
onPressed: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
final (_, message) = await _controller.approveAllPending(
|
||||
teacherId: widget.teacherId ?? '간편인증',
|
||||
teacherName: widget.teacherName ?? '간편인증 선생',
|
||||
);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(context, message);
|
||||
},
|
||||
child: const Text('전체 승인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🚨 [많은 시간요청 학생] 1시간 59분 넘게 신청한 대기 중인 요청만 따로 모아 보여준다.
|
||||
// 학생이 시간을 비정상적으로 길게 적어냈을 수 있으니, 전체 승인 전에 훑어보는 용도.
|
||||
void _showLongRequestsDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: AppPalette.warning),
|
||||
SizedBox(width: 8),
|
||||
Text('많은 시간요청 학생'),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 380,
|
||||
child: ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) {
|
||||
final longRequests = _controller.longPendingRequests;
|
||||
if (longRequests.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
'1시간 59분 넘게 신청한 대기 중인 요청이 없습니다.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 400),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: longRequests.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 20),
|
||||
itemBuilder: (context, index) {
|
||||
final r = longRequests[index];
|
||||
final hours = _controller.durationHours(r);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${r['studentName']} (${r['studentId']})',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${_timeRange(r['requestedStart'], r['requestedEnd'])} '
|
||||
'(약 ${hours?.toStringAsFixed(1)}시간)',
|
||||
style: const TextStyle(
|
||||
color: AppPalette.warning,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
r['purpose'],
|
||||
style: TextStyle(
|
||||
color: Colors.grey[700],
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _controller.isWorking
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(dialogContext);
|
||||
_reject(r['id']);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppPalette.danger,
|
||||
side: const BorderSide(
|
||||
color: AppPalette.danger,
|
||||
),
|
||||
),
|
||||
child: const Text('거절'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _controller.isWorking
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(dialogContext);
|
||||
_approve(r['id']);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
globalProfileTheme.value?.accent ??
|
||||
AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
),
|
||||
child: const Text('승인'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🖐️ [하드웨어 자동 감지 전까지 임시] 기기를 실제로 돌려받았을 때 누르는 버튼.
|
||||
Future<void> _confirmReturn(int id) async {
|
||||
final (_, message) = await _controller.confirmReturn(id);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
|
||||
({Color color, String label}) _statusInfo(String status) {
|
||||
switch (status) {
|
||||
case 'PENDING':
|
||||
return (color: Colors.orange, label: '대기중');
|
||||
return (color: AppPalette.warning, label: '대기중');
|
||||
case 'APPROVED':
|
||||
return (color: Colors.blue, label: '승인됨');
|
||||
return (color: AppPalette.approved, label: '승인됨');
|
||||
case 'RETURNED':
|
||||
return (color: Colors.grey, label: '반납완료');
|
||||
case 'REJECTED':
|
||||
return (color: Colors.red, label: '거절됨');
|
||||
return (color: AppPalette.danger, label: '거절됨');
|
||||
default:
|
||||
return (color: Colors.grey, label: status);
|
||||
}
|
||||
@@ -90,23 +259,37 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
|
||||
final pendingCount = requests
|
||||
.where((r) => r['status'] == 'PENDING')
|
||||
.length;
|
||||
final longCount = _controller.longPendingRequests.length;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'스마트기기 반출 대장',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const TitlePill('스마트기기 반출 대장'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
onPressed: _controller.fetchAll,
|
||||
tooltip: '새로고침',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.done_all_rounded),
|
||||
onPressed: _showApproveAllConfirmDialog,
|
||||
tooltip: '전체 학생 허용',
|
||||
),
|
||||
IconButton(
|
||||
icon: Badge(
|
||||
isLabelVisible: longCount > 0,
|
||||
label: Text('$longCount'),
|
||||
backgroundColor: AppPalette.danger,
|
||||
child: const Icon(Icons.warning_amber_rounded),
|
||||
),
|
||||
onPressed: _showLongRequestsDialog,
|
||||
tooltip: '많은 시간요청 학생',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _controller.isLoading
|
||||
@@ -122,14 +305,14 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange[100],
|
||||
color: AppPalette.warningBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.orange[400]!),
|
||||
border: Border.all(color: AppPalette.warning),
|
||||
),
|
||||
child: Text(
|
||||
'승인 대기 중인 요청이 $pendingCount건 있습니다.',
|
||||
style: TextStyle(
|
||||
color: Colors.orange[900],
|
||||
color: AppPalette.warning,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -153,6 +336,10 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
|
||||
final String status = r['status'];
|
||||
final info = _statusInfo(status);
|
||||
final bool isPending = status == 'PENDING';
|
||||
// 🚨 1시간 59분 넘게 신청했으면 상태와 무관하게 빨간 테두리로 눈에 띄게.
|
||||
final bool isLong = _controller.isLongRequest(
|
||||
r,
|
||||
);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
@@ -160,9 +347,18 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: isPending
|
||||
? BorderSide(color: Colors.orange[300]!)
|
||||
: BorderSide(color: AppPalette.sage),
|
||||
side: isLong
|
||||
? const BorderSide(
|
||||
color: AppPalette.danger,
|
||||
width: 2,
|
||||
)
|
||||
: (isPending
|
||||
? const BorderSide(
|
||||
color: AppPalette.warning,
|
||||
)
|
||||
: BorderSide(
|
||||
color: AppPalette.sage,
|
||||
)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -231,7 +427,7 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
|
||||
Text(
|
||||
'${r['approvedByTeacherName']} 선생님이 허용했습니다',
|
||||
style: TextStyle(
|
||||
color: Colors.blue[700],
|
||||
color: AppPalette.approved,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
@@ -250,9 +446,10 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
|
||||
style:
|
||||
OutlinedButton.styleFrom(
|
||||
foregroundColor:
|
||||
Colors.red,
|
||||
AppPalette.danger,
|
||||
side: const BorderSide(
|
||||
color: Colors.red,
|
||||
color:
|
||||
AppPalette.danger,
|
||||
),
|
||||
),
|
||||
child: const Text('거절'),
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/device_checkout_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../theme/theme_sync.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class DeviceCheckoutRequestScreen extends StatefulWidget {
|
||||
final String studentId;
|
||||
@@ -57,9 +60,7 @@ class _DeviceCheckoutRequestScreenState
|
||||
Future<void> _submit() async {
|
||||
final purpose = _purposeController.text.trim();
|
||||
if (_startTime == null || _endTime == null || purpose.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('시작/종료 시각과 사용 목적을 모두 입력해주세요.')),
|
||||
);
|
||||
AppNotice.show(context, '시작/종료 시각과 사용 목적을 모두 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,9 +72,7 @@ class _DeviceCheckoutRequestScreenState
|
||||
endTime: _formatTime(_endTime!),
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
AppNotice.show(context, message);
|
||||
if (success) Navigator.pop(context);
|
||||
}
|
||||
|
||||
@@ -85,16 +84,17 @@ class _DeviceCheckoutRequestScreenState
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'스마트기기 반출 신청',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const TitlePill('스마트기기 반출 신청'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -160,9 +160,13 @@ class _DeviceCheckoutRequestScreenState
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: _controller.isSubmitting ? null : _submit,
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: _submit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppPalette.ink,
|
||||
backgroundColor:
|
||||
globalProfileTheme.value?.accent ??
|
||||
AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -187,6 +191,8 @@ class _DeviceCheckoutRequestScreenState
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
// 🧑🤝🧑 백품타 친구 화면 (UI 전용). "실시간 출석 현황"과 같은 패밀리룩을 따른다.
|
||||
// 서버 통신/상태는 lib/function/friend_controller.dart가 담당한다.
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/friend_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../theme/theme_sync.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<FriendsScreen> createState() => _FriendsScreenState();
|
||||
}
|
||||
|
||||
class _FriendsScreenState extends State<FriendsScreen> {
|
||||
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<void> _showAddFriendDialog() async {
|
||||
final idController = TextEditingController();
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('친구 추가'),
|
||||
content: TextField(
|
||||
controller: idController,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => Navigator.pop(dialogContext, true),
|
||||
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:
|
||||
globalProfileTheme.value?.accent ?? 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<void> _respond(int requestId, bool accept) async {
|
||||
final (_, message) = await _controller.respondRequest(requestId, accept);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
|
||||
Future<void> _remove(String friendId) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('삭제', style: TextStyle(color: AppPalette.danger)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
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: globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
onPressed: _showAddFriendDialog,
|
||||
icon: const Icon(Icons.person_add_rounded),
|
||||
label: const Text('친구 추가'),
|
||||
),
|
||||
body: _controller.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: 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:
|
||||
globalProfileTheme.value?.accent ??
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
// 👥 백품타 그룹 상세 화면 (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 '../theme/theme_sync.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'launchpad_transition.dart';
|
||||
import 'study_timer_screen.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class GroupDetailScreen extends StatefulWidget {
|
||||
final int groupId;
|
||||
final String groupName;
|
||||
final String studentId;
|
||||
final String studentName;
|
||||
final int? grade;
|
||||
|
||||
const GroupDetailScreen({
|
||||
super.key,
|
||||
required this.groupId,
|
||||
required this.groupName,
|
||||
required this.studentId,
|
||||
required this.studentName,
|
||||
this.grade,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GroupDetailScreen> createState() => _GroupDetailScreenState();
|
||||
}
|
||||
|
||||
class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||
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<void> _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<void>(
|
||||
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:
|
||||
globalProfileTheme.value?.accent ??
|
||||
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<void> _toggleVisibility() async {
|
||||
final makePublic = !_controller.isPublic;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(makePublic ? '공개 그룹으로 전환' : '비공개 그룹으로 전환'),
|
||||
content: Text(
|
||||
makePublic
|
||||
? '이제 누구나 이 그룹을 찾아서 참여 신청을 보낼 수 있어요. 참여는 그룹장이 허용해야 완료됩니다.'
|
||||
: '더 이상 공개 목록에 뜨지 않고, 대기 중인 참여 신청은 모두 거절 처리됩니다.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
autofocus: true,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
),
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('전환'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
final (_, message) = await _controller.setVisibility(makePublic);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
|
||||
Future<void> _respondJoinRequest(int requestId, bool accept) async {
|
||||
final (_, message) = await _controller.respondJoinRequest(
|
||||
requestId,
|
||||
accept,
|
||||
);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
|
||||
Future<void> _leave() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text(
|
||||
'나가기',
|
||||
style: TextStyle(color: AppPalette.danger),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
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 h = seconds ~/ 3600;
|
||||
final m = (seconds % 3600) ~/ 60;
|
||||
final s = seconds % 60;
|
||||
if (h > 0) return '$h시간 $m분';
|
||||
if (m > 0) return '$m분 $s초';
|
||||
return '$s초';
|
||||
}
|
||||
|
||||
/// 🎨 오늘 공부 시간 30분 단위로 프로필 테두리 색이 흰→노랑→주황→빨강→보라로
|
||||
/// 진해진다. 2시간(4단계) 넘으면 보라색에서 멈춘다.
|
||||
static const List<Color> _studyFrameColors = [
|
||||
Color(0xFFE0E0E0), // 흰(기본)
|
||||
Color(0xFFFFC107), // 노랑
|
||||
Color(0xFFFF9800), // 주황
|
||||
Color(0xFFE53935), // 빨강
|
||||
Color(0xFF8E24AA), // 보라
|
||||
];
|
||||
|
||||
Color _studyFrameColor(int todaySeconds) {
|
||||
final tier = (todaySeconds ~/ 1800).clamp(0, _studyFrameColors.length - 1);
|
||||
return _studyFrameColors[tier];
|
||||
}
|
||||
|
||||
void _openTimer() {
|
||||
pushLaunchpad(
|
||||
context,
|
||||
(context) => StudyTimerScreen(
|
||||
studentId: widget.studentId,
|
||||
studentName: widget.studentName,
|
||||
grade: widget.grade,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 넓은 화면(웹 데스크톱)에서 내용이 양옆으로 늘어지지 않게 가운데 최대 560 폭으로 맞춘다.
|
||||
double _sidePad(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
return ((width - 560) / 2).clamp(16.0, double.infinity);
|
||||
}
|
||||
|
||||
@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: [
|
||||
if (_controller.isOwner)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_controller.isPublic
|
||||
? Icons.public_rounded
|
||||
: Icons.lock_rounded,
|
||||
),
|
||||
tooltip: _controller.isPublic ? '공개 그룹' : '비공개 그룹',
|
||||
onPressed: _toggleVisibility,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.exit_to_app_rounded),
|
||||
tooltip: '그룹 나가기',
|
||||
onPressed: _leave,
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
backgroundColor: globalProfileTheme.value?.accent ?? 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: EdgeInsets.fromLTRB(
|
||||
_sidePad(context),
|
||||
0,
|
||||
_sidePad(context),
|
||||
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: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _openTimer,
|
||||
icon: const Icon(Icons.timer_rounded),
|
||||
label: const Text('백품타 바로가기'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppPalette.ink,
|
||||
side: BorderSide(color: AppPalette.sage),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_controller.isOwner &&
|
||||
_controller.joinRequests.isNotEmpty) ...[
|
||||
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(width: 6),
|
||||
Text(
|
||||
'${_controller.joinRequests.length}명',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
for (final r in _controller.joinRequests)
|
||||
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: () => _respondJoinRequest(
|
||||
r['requestId'] as int,
|
||||
false,
|
||||
),
|
||||
child: const Text(
|
||||
'거절',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
globalProfileTheme.value?.accent ??
|
||||
AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
),
|
||||
onPressed: () => _respondJoinRequest(
|
||||
r['requestId'] as int,
|
||||
true,
|
||||
),
|
||||
child: const Text('허용'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
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: 4),
|
||||
Text(
|
||||
'1시간 이상 공부해야 출석으로 표시돼요.',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'프로필 테두리는 30분마다 색이 진해져요',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
for (final c in _studyFrameColors)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 2),
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: c,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 150,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisExtent: 96,
|
||||
),
|
||||
itemCount: members.length,
|
||||
itemBuilder: (context, index) {
|
||||
final m = members[index];
|
||||
final attended = m['attendedToday'] == true;
|
||||
final todaySeconds =
|
||||
(m['todaySeconds'] as num?)?.toInt() ?? 0;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: attended
|
||||
? AppPalette.ink
|
||||
: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: attended
|
||||
? AppPalette.ink
|
||||
: AppPalette.sage,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
padding: const EdgeInsets.all(2.5),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: _studyFrameColor(todaySeconds),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: attended
|
||||
? Colors.white24
|
||||
: AppPalette.linen,
|
||||
child: Icon(
|
||||
Icons.person_rounded,
|
||||
size: 13,
|
||||
color: attended
|
||||
? Colors.white
|
||||
: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${m['name']}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
color: attended
|
||||
? Colors.white
|
||||
: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
if (m['role'] == 'owner')
|
||||
Text(
|
||||
'그룹장',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: attended
|
||||
? Colors.white70
|
||||
: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatMinutes(todaySeconds),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: attended
|
||||
? Colors.white70
|
||||
: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
// 👥 백품타 그룹 스터디 목록 화면 (UI 전용). "실시간 출석 현황"과 같은 패밀리룩을 따른다.
|
||||
// 서버 통신/상태는 lib/function/group_controller.dart가 담당한다.
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/group_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../theme/theme_sync.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;
|
||||
final int? grade;
|
||||
|
||||
const GroupListScreen({
|
||||
super.key,
|
||||
required this.studentId,
|
||||
required this.studentName,
|
||||
this.grade,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GroupListScreen> createState() => _GroupListScreenState();
|
||||
}
|
||||
|
||||
class _GroupListScreenState extends State<GroupListScreen> {
|
||||
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<void> _showCreateGroupDialog() async {
|
||||
final nameController = TextEditingController();
|
||||
bool isPublic = false;
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (dialogContext, setDialogState) => AlertDialog(
|
||||
title: const Text('그룹 만들기'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
autofocus: true,
|
||||
maxLength: 20,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => Navigator.pop(dialogContext, true),
|
||||
decoration: const InputDecoration(
|
||||
labelText: '그룹 이름',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('공개 그룹으로 만들기'),
|
||||
subtitle: const Text(
|
||||
'누구나 목록에서 보고 참여 신청할 수 있어요\n(그룹장이 허용해야 들어옵니다)',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
value: isPublic,
|
||||
activeThumbColor: AppPalette.ink,
|
||||
onChanged: (v) => setDialogState(() => isPublic = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
globalProfileTheme.value?.accent ?? 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(),
|
||||
isPublic: isPublic,
|
||||
);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(
|
||||
context,
|
||||
message,
|
||||
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _respondInvite(int inviteId, bool accept) async {
|
||||
final (_, message) = await _controller.respondInvite(inviteId, accept);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
|
||||
Future<void> _requestJoin(int groupId) async {
|
||||
final (success, message) = await _controller.requestJoin(groupId);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(
|
||||
context,
|
||||
message,
|
||||
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openGroup(dynamic group) async {
|
||||
await pushLaunchpad(
|
||||
context,
|
||||
(context) => GroupDetailScreen(
|
||||
groupId: group['groupId'] as int,
|
||||
groupName: group['name'] as String,
|
||||
studentId: widget.studentId,
|
||||
studentName: widget.studentName,
|
||||
grade: widget.grade,
|
||||
),
|
||||
);
|
||||
_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: globalProfileTheme.value?.accent ?? AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
onPressed: _showCreateGroupDialog,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('그룹 만들기'),
|
||||
),
|
||||
body: _controller.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: 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:
|
||||
globalProfileTheme.value?.accent ??
|
||||
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: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${g['name']}',
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight:
|
||||
FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (g['isPublic'] == true) ...[
|
||||
const SizedBox(width: 6),
|
||||
const Icon(
|
||||
Icons.public_rounded,
|
||||
size: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
if ((g['pendingRequestCount'] ??
|
||||
0) >
|
||||
0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
AppPalette.warningBg,
|
||||
borderRadius:
|
||||
BorderRadius.circular(
|
||||
20,
|
||||
),
|
||||
border: Border.all(
|
||||
color:
|
||||
AppPalette.warning,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'대기 ${g['pendingRequestCount']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color:
|
||||
AppPalette.warning,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'멤버 ${g['memberCount']}명'
|
||||
'${g['isOwner'] == true ? ' · 그룹장' : ''}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_controller.publicGroups.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
_sectionHeader(
|
||||
'공개 그룹',
|
||||
count: _controller.publicGroups.length,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
for (final g in _controller.publicGroups)
|
||||
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: [
|
||||
const Icon(
|
||||
Icons.public_rounded,
|
||||
color: AppPalette.ink,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${g['name']}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${g['ownerName']} · 멤버 ${g['memberCount']}명',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (g['requested'] == true)
|
||||
Text(
|
||||
'신청됨',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
)
|
||||
else
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
globalProfileTheme
|
||||
.value
|
||||
?.accent ??
|
||||
AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
),
|
||||
onPressed: () =>
|
||||
_requestJoin(g['groupId'] as int),
|
||||
child: const Text('참여 신청'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// 👁️ 비밀번호 입력창 - 오른쪽 눈 아이콘을 누르고 있는 동안만 평문으로 보여준다.
|
||||
// 토글형(눌러서 계속 보기) 대신 "홀드"로 만든 이유: 손을 떼는 순간 바로 다시 가려져서
|
||||
// 누가 옆에서 보더라도 짧게 확인만 하고 끝낼 수 있다.
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class HoldToRevealPasswordField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String labelText;
|
||||
final String? hintText;
|
||||
final IconData prefixIcon;
|
||||
final bool autofocus;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
|
||||
const HoldToRevealPasswordField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.labelText,
|
||||
this.hintText,
|
||||
this.prefixIcon = Icons.lock,
|
||||
this.autofocus = false,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.onSubmitted,
|
||||
});
|
||||
|
||||
@override
|
||||
State<HoldToRevealPasswordField> createState() =>
|
||||
_HoldToRevealPasswordFieldState();
|
||||
}
|
||||
|
||||
class _HoldToRevealPasswordFieldState extends State<HoldToRevealPasswordField> {
|
||||
bool _revealed = false;
|
||||
|
||||
void _setRevealed(bool value) {
|
||||
if (_revealed != value) setState(() => _revealed = value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: widget.controller,
|
||||
obscureText: !_revealed,
|
||||
autofocus: widget.autofocus,
|
||||
keyboardType: widget.keyboardType,
|
||||
textInputAction: widget.textInputAction,
|
||||
onSubmitted: widget.onSubmitted,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.labelText,
|
||||
hintText: widget.hintText,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: Icon(widget.prefixIcon),
|
||||
suffixIcon: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => _setRevealed(true),
|
||||
onTapUp: (_) => _setRevealed(false),
|
||||
onTapCancel: () => _setRevealed(false),
|
||||
child: Icon(
|
||||
_revealed ? Icons.visibility_rounded : Icons.visibility_off_rounded,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+57
-68
@@ -5,6 +5,8 @@ import '../config.dart' show schoolName;
|
||||
import '../function/login_controller.dart';
|
||||
import '../function/session_store.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'hold_to_reveal_password_field.dart';
|
||||
import 'main_dashboard.dart';
|
||||
import 'teacher_register_screen.dart';
|
||||
|
||||
@@ -58,9 +60,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
await SessionStore.clear();
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('개발자 최고 권한으로 로그인되었습니다.')));
|
||||
AppNotice.show(context, '개발자 최고 권한으로 로그인되었습니다.');
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -79,17 +79,17 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
result.name!,
|
||||
result.role!,
|
||||
result.isDeviceMatched,
|
||||
result.grade,
|
||||
);
|
||||
break;
|
||||
case LoginOutcome.success:
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('${result.name}님 환영합니다!')));
|
||||
AppNotice.show(context, '${result.name}님 환영합니다!');
|
||||
_navigateBasedOnRole(
|
||||
result.role!,
|
||||
result.studentId!,
|
||||
result.name!,
|
||||
result.isDeviceMatched,
|
||||
result.grade,
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -101,6 +101,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
String name,
|
||||
String role,
|
||||
bool isDeviceMatched,
|
||||
int? grade,
|
||||
) {
|
||||
final TextEditingController newPwController = TextEditingController();
|
||||
|
||||
@@ -112,6 +113,36 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
Future<void> submit() async {
|
||||
String newPassword = newPwController.text.trim();
|
||||
if (newPassword.isEmpty) {
|
||||
AppNotice.show(context, '새 비밀번호를 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogState(() => isUpdating = true);
|
||||
|
||||
final (success, message) = await _controller.changePassword(
|
||||
studentId: studentId,
|
||||
newPassword: newPassword,
|
||||
);
|
||||
setDialogState(() => isUpdating = false);
|
||||
|
||||
if (success) {
|
||||
Navigator.pop(dialogContext);
|
||||
AppNotice.show(context, message);
|
||||
_navigateBasedOnRole(
|
||||
role,
|
||||
studentId,
|
||||
name,
|
||||
isDeviceMatched,
|
||||
grade,
|
||||
);
|
||||
} else {
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text(
|
||||
'초기 비밀번호 변경',
|
||||
@@ -122,17 +153,16 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
children: [
|
||||
const Text(
|
||||
'보안을 위해 새로운 비밀번호를 설정해 주세요.',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
style: TextStyle(color: AppPalette.danger),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
HoldToRevealPasswordField(
|
||||
controller: newPwController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
autofocus: true,
|
||||
labelText: '새 비밀번호',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.lock_reset),
|
||||
),
|
||||
prefixIcon: Icons.lock_reset,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -147,41 +177,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () async {
|
||||
String newPassword = newPwController.text.trim();
|
||||
if (newPassword.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('새 비밀번호를 입력해 주세요.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogState(() => isUpdating = true);
|
||||
|
||||
final (success, message) = await _controller
|
||||
.changePassword(
|
||||
studentId: studentId,
|
||||
newPassword: newPassword,
|
||||
);
|
||||
setDialogState(() => isUpdating = false);
|
||||
|
||||
if (success) {
|
||||
Navigator.pop(dialogContext);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
_navigateBasedOnRole(
|
||||
role,
|
||||
studentId,
|
||||
name,
|
||||
isDeviceMatched,
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
},
|
||||
onPressed: submit,
|
||||
child: const Text('변경하고 시작하기'),
|
||||
),
|
||||
],
|
||||
@@ -198,6 +194,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
String studentId,
|
||||
String name,
|
||||
bool isDeviceMatched,
|
||||
int? grade,
|
||||
) async {
|
||||
if (_keepLoggedIn) {
|
||||
await SessionStore.save(
|
||||
@@ -206,6 +203,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
userName: name,
|
||||
role: role,
|
||||
isDeviceMatched: isDeviceMatched,
|
||||
grade: grade,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -220,6 +218,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
userName: name,
|
||||
role: role,
|
||||
isDeviceMatched: isDeviceMatched,
|
||||
grade: grade,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -236,6 +235,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('확인'),
|
||||
),
|
||||
@@ -256,9 +256,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text('$title 권한 인증 (기존 방식)'),
|
||||
content: TextField(
|
||||
content: HoldToRevealPasswordField(
|
||||
controller: passwordController,
|
||||
obscureText: true,
|
||||
autofocus: true,
|
||||
labelText: '비밀번호',
|
||||
hintText: '비밀번호 4자리를 입력하세요',
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (value) {
|
||||
@@ -269,15 +271,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
MaterialPageRoute(builder: (context) => nextPage),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('비밀번호가 올바르지 않습니다.')),
|
||||
);
|
||||
AppNotice.show(context, '비밀번호가 올바르지 않습니다.');
|
||||
}
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
hintText: '비밀번호 4자리를 입력하세요',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -293,9 +289,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
MaterialPageRoute(builder: (context) => nextPage),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('비밀번호가 올바르지 않습니다.')),
|
||||
);
|
||||
AppNotice.show(context, '비밀번호가 올바르지 않습니다.');
|
||||
}
|
||||
},
|
||||
child: const Text('인증하기'),
|
||||
@@ -347,16 +341,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
HoldToRevealPasswordField(
|
||||
controller: _pwController,
|
||||
obscureText: true,
|
||||
labelText: '비밀번호',
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _login(),
|
||||
decoration: const InputDecoration(
|
||||
labelText: '비밀번호',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.lock),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
InkWell(
|
||||
@@ -438,7 +427,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
onPressed: () => _showLegacyPasswordDialog(
|
||||
'선생님',
|
||||
'1234',
|
||||
'4885',
|
||||
const MainDashboard(role: 'teacher'),
|
||||
),
|
||||
),
|
||||
|
||||
+274
-38
@@ -3,23 +3,36 @@
|
||||
// 타일 배치(위치/크기)는 사용자가 직접 편집할 수 있고, 계정별로 서버에 저장된다
|
||||
// (lib/function/dashboard_layout_controller.dart). 캔버스는 가로 6칸×세로 4칸,
|
||||
// 타일 크기는 1x1~4x4.
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import '../function/dashboard_layout_controller.dart';
|
||||
import '../function/profile_shop_controller.dart';
|
||||
import '../function/session_store.dart';
|
||||
import '../function/student_dashboard_controller.dart';
|
||||
import '../models/dashboard_tile_layout.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../theme/profile_themes.dart';
|
||||
import '../theme/theme_sync.dart';
|
||||
import 'admin_dashboard.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'board_home_screen.dart';
|
||||
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 'profile_customize_screen.dart';
|
||||
import 'study_calendar_screen.dart';
|
||||
import 'study_timer_screen.dart';
|
||||
import 'teacher_attendance_page.dart';
|
||||
import 'teacher_call_screen.dart';
|
||||
import 'teacher_location_screen.dart';
|
||||
import 'teacher_student_management_page.dart';
|
||||
|
||||
class MainDashboard extends StatefulWidget {
|
||||
@@ -27,6 +40,7 @@ class MainDashboard extends StatefulWidget {
|
||||
final String? userName;
|
||||
final String role; // 'student' | 'teacher' | 'admin'
|
||||
final bool isDeviceMatched;
|
||||
final int? grade; // 🎓 공부 타이머 학년별 랭킹에서 본인 학년을 기본값으로 쓰기 위함.
|
||||
|
||||
const MainDashboard({
|
||||
super.key,
|
||||
@@ -34,6 +48,7 @@ class MainDashboard extends StatefulWidget {
|
||||
this.userName,
|
||||
required this.role,
|
||||
this.isDeviceMatched = true,
|
||||
this.grade,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -67,6 +82,24 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
if (kTileEditingEnabled) {
|
||||
_layout.load(_tileSpecs().map((t) => t.id).toList());
|
||||
}
|
||||
if (_isStudent || _isTeacherOrAbove) {
|
||||
_loadEquippedTheme();
|
||||
}
|
||||
}
|
||||
|
||||
// 🛍️ 프로필 꾸미기 상점에서 장착한 템플릿이 몰고 오는 테마 (없으면 기본 모습 그대로).
|
||||
ProfileTheme? _equippedTheme;
|
||||
|
||||
// 🎨 타일 아이콘 등 강조색에 쓸 색 - 테마가 없으면 원래 쓰던 AppPalette.ink.
|
||||
Color get _tileAccent => _equippedTheme?.accent ?? AppPalette.ink;
|
||||
|
||||
Future<void> _loadEquippedTheme() async {
|
||||
final theme = await ProfileShopController.fetchEquippedTheme(_displayId);
|
||||
applyGlobalProfileTheme(theme);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_equippedTheme = theme;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -104,7 +137,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
),
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.redAccent),
|
||||
Icon(Icons.warning_amber_rounded, color: AppPalette.danger),
|
||||
SizedBox(width: 10),
|
||||
Text(
|
||||
'계정 강제 삭제',
|
||||
@@ -127,13 +160,25 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: idController,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (value) {
|
||||
final inputId = value.trim();
|
||||
if (inputId.isNotEmpty) {
|
||||
Navigator.pop(context);
|
||||
_deleteUser(inputId);
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: '학번 또는 교직원 번호',
|
||||
hintText: '예: 201101 또는 T1001',
|
||||
labelStyle: TextStyle(color: Colors.red[400]),
|
||||
labelStyle: const TextStyle(color: AppPalette.danger),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: Colors.red[400]!, width: 2),
|
||||
borderSide: const BorderSide(
|
||||
color: AppPalette.danger,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -163,7 +208,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.redAccent,
|
||||
backgroundColor: AppPalette.danger,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -270,7 +315,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
: Icons.lock_rounded,
|
||||
title: 'NFC 태그',
|
||||
subtitle: widget.isDeviceMatched ? '출석 및 폰 수거 완료' : '본인 인증 기기 전용',
|
||||
color: widget.isDeviceMatched ? AppPalette.ink : AppPalette.sage,
|
||||
color: widget.isDeviceMatched ? _tileAccent : AppPalette.sage,
|
||||
onTap: widget.isDeviceMatched
|
||||
? () => _openPocketCheckIn(context)
|
||||
: () => AppNotice.show(
|
||||
@@ -288,7 +333,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
icon: Icons.fastfood_rounded,
|
||||
title: '실시간 학교 상황',
|
||||
subtitle: '급식실 줄 & 매점 재고 확인',
|
||||
color: AppPalette.ink,
|
||||
color: _tileAccent,
|
||||
onTap: () => AppNotice.show(
|
||||
context,
|
||||
'실시간 학교 상황 페이지로 이동합니다.',
|
||||
@@ -302,7 +347,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
icon: Icons.tablet_mac_rounded,
|
||||
title: '스마트기기 반출',
|
||||
subtitle: '패드 사용 신청 (시간/목적)',
|
||||
color: AppPalette.ink,
|
||||
color: _tileAccent,
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
(context) => DeviceCheckoutRequestScreen(
|
||||
@@ -312,6 +357,96 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
),
|
||||
),
|
||||
));
|
||||
tiles.add((
|
||||
id: 'study_timer',
|
||||
child: _buildModernCard(
|
||||
icon: Icons.timer_rounded,
|
||||
title: '백품타',
|
||||
subtitle: '공부 타이머 | 학년별 공부 랭킹',
|
||||
color: _tileAccent,
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
(context) => StudyTimerScreen(
|
||||
studentId: _displayId,
|
||||
studentName: _displayName,
|
||||
grade: widget.grade,
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
tiles.add((
|
||||
id: 'community_board',
|
||||
child: _buildModernCard(
|
||||
icon: Icons.forum_rounded,
|
||||
title: '백판',
|
||||
subtitle: '익명 커뮤니티 게시판',
|
||||
color: _tileAccent,
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
(context) => BoardHomeScreen(studentId: _displayId),
|
||||
),
|
||||
),
|
||||
));
|
||||
tiles.add((
|
||||
id: 'teacher_call',
|
||||
child: _buildModernCard(
|
||||
icon: Icons.campaign_rounded,
|
||||
title: '선생님 호출',
|
||||
subtitle: '교무실 위치 확인 및 호출',
|
||||
color: _tileAccent,
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
(context) => TeacherCallScreen(
|
||||
studentId: _displayId,
|
||||
studentName: _displayName,
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
tiles.add((
|
||||
id: 'friends',
|
||||
child: _buildModernCard(
|
||||
icon: Icons.people_alt_rounded,
|
||||
title: '친구',
|
||||
subtitle: '학번으로 친구 추가',
|
||||
color: _tileAccent,
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
(context) =>
|
||||
FriendsScreen(studentId: _displayId, studentName: _displayName),
|
||||
),
|
||||
),
|
||||
));
|
||||
tiles.add((
|
||||
id: 'group_study',
|
||||
child: _buildModernCard(
|
||||
icon: Icons.groups_rounded,
|
||||
title: '그룹 스터디',
|
||||
subtitle: '친구랑 그룹 만들어 같이 공부',
|
||||
color: _tileAccent,
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
(context) => GroupListScreen(
|
||||
studentId: _displayId,
|
||||
studentName: _displayName,
|
||||
grade: widget.grade,
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
tiles.add((
|
||||
id: 'study_calendar',
|
||||
child: _buildModernCard(
|
||||
icon: Icons.calendar_month_rounded,
|
||||
title: '출석 달력',
|
||||
subtitle: '연속 출석 기록 확인',
|
||||
color: _tileAccent,
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
(context) => StudyCalendarScreen(studentId: _displayId),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
if (_isTeacherOrAbove) {
|
||||
@@ -321,7 +456,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
icon: Icons.assignment_turned_in_rounded,
|
||||
title: '실시간 출석 확인',
|
||||
subtitle: '학생 제출 로그 모니터링',
|
||||
color: AppPalette.ink,
|
||||
color: _tileAccent,
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
(context) => const TeacherAttendancePage(),
|
||||
@@ -334,7 +469,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
icon: Icons.manage_accounts_rounded,
|
||||
title: '학생 계정 관리',
|
||||
subtitle: '계정 추가 및 강제 리셋',
|
||||
color: AppPalette.ink,
|
||||
color: _tileAccent,
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
(context) => const TeacherStudentManagementPage(),
|
||||
@@ -347,7 +482,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
icon: Icons.tablet_mac_rounded,
|
||||
title: '스마트기기 반출 대장',
|
||||
subtitle: '패드 반출 신청 승인/거절',
|
||||
color: AppPalette.ink,
|
||||
color: _tileAccent,
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
(context) => DeviceCheckoutLedgerPage(
|
||||
@@ -357,6 +492,48 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
),
|
||||
),
|
||||
));
|
||||
tiles.add((
|
||||
id: 'board_moderation',
|
||||
child: _buildModernCard(
|
||||
icon: Icons.shield_outlined,
|
||||
title: '백판 신고 검토',
|
||||
subtitle: '학생 게시판 신고 처리',
|
||||
color: _tileAccent,
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
(context) => BoardReportScreen(reviewerId: _displayId),
|
||||
),
|
||||
),
|
||||
));
|
||||
tiles.add((
|
||||
id: 'study_calendar_admin',
|
||||
child: _buildModernCard(
|
||||
icon: Icons.event_available_rounded,
|
||||
title: '출석 달력 관리',
|
||||
subtitle: '날짜별 선물/이벤트 등록',
|
||||
color: _tileAccent,
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
(context) => StudyCalendarScreen(
|
||||
studentId: _displayId,
|
||||
canManageEvents: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
tiles.add((
|
||||
id: 'teacher_location',
|
||||
child: _buildModernCard(
|
||||
icon: Icons.my_location_rounded,
|
||||
title: '내 위치 알리기',
|
||||
subtitle: '학생 호출 확인 및 위치 등록',
|
||||
color: _tileAccent,
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
(context) => TeacherLocationScreen(teacherId: _displayId),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
if (_isAdmin) {
|
||||
@@ -366,7 +543,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
icon: Icons.terminal_rounded,
|
||||
title: '서버 DB 제어',
|
||||
subtitle: '시스템 원격 초기화',
|
||||
color: AppPalette.ink,
|
||||
color: _tileAccent,
|
||||
onTap: () =>
|
||||
pushLaunchpad(context, (context) => const AdminDashboard()),
|
||||
),
|
||||
@@ -377,7 +554,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
icon: Icons.delete_sweep_rounded,
|
||||
title: '계정 강제 삭제',
|
||||
subtitle: '학생 및 교사 DB 삭제',
|
||||
color: AppPalette.ink,
|
||||
color: _tileAccent,
|
||||
onTap: () => _showDeleteUserDialog(context),
|
||||
),
|
||||
));
|
||||
@@ -387,7 +564,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
icon: Icons.edit_note_rounded,
|
||||
title: 'NFC 태그 쓰기',
|
||||
subtitle: '주머니 스티커 초기 설정',
|
||||
color: AppPalette.ink,
|
||||
color: _tileAccent,
|
||||
onTap: () =>
|
||||
pushLaunchpad(context, (context) => const NfcTagWriterScreen()),
|
||||
),
|
||||
@@ -449,7 +626,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
final bool isEditing = _layout.isEditing;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
backgroundColor: _equippedTheme?.background ?? AppPalette.mist,
|
||||
// 🪶 큰 색상 배너 대신 작은 프로필 카드를 본문에 두므로, 앱바는 ">" 메뉴
|
||||
// 버튼(또는 편집 중 "완료" 버튼)만 올려두는 투명한 얇은 바로 둔다.
|
||||
appBar: AppBar(
|
||||
@@ -489,17 +666,31 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
// 🪪 작은 프로필 카드로 배치 — 화면 전체를 차지하던 배너 대신
|
||||
// 가운데에 떠 있는 카드 하나만 둔다.
|
||||
Center(
|
||||
child: Container(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 28,
|
||||
vertical: 18,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.color,
|
||||
color: _equippedTheme == null ? theme.color : null,
|
||||
gradient: _equippedTheme == null
|
||||
? null
|
||||
: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
_equippedTheme!.primary,
|
||||
_equippedTheme!.secondary,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.color.withValues(alpha: 0.25),
|
||||
color: (_equippedTheme?.primary ?? theme.color)
|
||||
.withValues(alpha: 0.25),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
@@ -556,6 +747,41 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: -6,
|
||||
right: -6,
|
||||
child: Material(
|
||||
color: AppPalette.paper,
|
||||
shape: const CircleBorder(),
|
||||
elevation: 2,
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () async {
|
||||
await pushLaunchpad(
|
||||
context,
|
||||
(context) => ProfileCustomizeScreen(
|
||||
studentId: _displayId,
|
||||
studentName: _displayName,
|
||||
),
|
||||
);
|
||||
// 상점에서 테마를 바꾸고 돌아왔을 수 있으니 다시 불러온다.
|
||||
if (_isStudent || _isTeacherOrAbove) {
|
||||
_loadEquippedTheme();
|
||||
}
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
Icons.edit_rounded,
|
||||
size: 16,
|
||||
color: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Padding(
|
||||
@@ -584,8 +810,8 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
),
|
||||
),
|
||||
if (isEditing)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8, left: 24, right: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, left: 24, right: 24),
|
||||
child: Text(
|
||||
'타일을 드래그해서 옮기고, 오른쪽 아래 손잡이로 크기를 바꿔보세요.',
|
||||
textAlign: TextAlign.center,
|
||||
@@ -629,15 +855,19 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final double width = constraints.maxWidth;
|
||||
// 🔲 정사각형 1칸 기준 4×3 배치 — 화면이 넓어도 6칸까지 늘리지 않고 4칸에서 멈춘다.
|
||||
final int crossAxisCount = width < 420
|
||||
// 🔲 정사각형 1칸 기준 배치. PC로 접속한 웹(넓은 화면)에서는 6칸까지 늘려서
|
||||
// 타일이 한 화면에 다 들어오게 하고(스크롤/바운스 자체가 안 생기게), 폰 화면이나
|
||||
// 앱(Android)에서는 기존처럼 4칸에서 멈춘다.
|
||||
final int crossAxisCount = kIsWeb && width >= 900
|
||||
? 6
|
||||
: width < 420
|
||||
? 2
|
||||
: width < 700
|
||||
? 3
|
||||
: 4;
|
||||
// 📏 1칸(1x1) 크기가 대략 계정 프로필 카드만 해지도록 목표 크기를 잡고,
|
||||
// 화면이 그보다 넓으면 가운데로 모아서 여백을 준다(좁으면 화면 폭에 맞춤).
|
||||
const double targetCellSize = 184;
|
||||
// 📏 1칸(1x1) 크기 목표. 화면이 그보다 넓으면 가운데로 모아서 여백을
|
||||
// 주고, 좁으면(폰) 화면 폭에 맞춘다.
|
||||
const double targetCellSize = 226;
|
||||
const double gap = 12;
|
||||
final double idealGridWidth =
|
||||
crossAxisCount * targetCellSize + (crossAxisCount - 1) * gap;
|
||||
@@ -734,14 +964,14 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.ink,
|
||||
color: _tileAccent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppPalette.paper,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
child: Icon(
|
||||
Icons.open_in_full_rounded,
|
||||
size: 14,
|
||||
color: AppPalette.paper,
|
||||
@@ -787,13 +1017,13 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
: constraints.maxHeight
|
||||
: constraints.maxWidth;
|
||||
final double scale = (referenceSize / 110).clamp(1.0, 3.4);
|
||||
final double iconBoxPadding = 6 + 6 * (scale - 1);
|
||||
final double iconSize = 16 + 10 * (scale - 1);
|
||||
final double iconRadius = 10 + 6 * (scale - 1);
|
||||
final double cardPadding = 8 + 8 * (scale - 1);
|
||||
final double iconBoxPadding = 8 + 6 * (scale - 1);
|
||||
final double iconSize = 22 + 10 * (scale - 1);
|
||||
final double iconRadius = 12 + 6 * (scale - 1);
|
||||
final double cardPadding = 10 + 8 * (scale - 1);
|
||||
final double cardRadius = 16 + 6 * (scale - 1);
|
||||
final double titleFontSize = (10 + 3 * (scale - 1)).clamp(10, 18);
|
||||
final double subtitleFontSize = (8 + 2 * (scale - 1)).clamp(8, 14);
|
||||
final double titleFontSize = (12 + 3 * (scale - 1)).clamp(12, 20);
|
||||
final double subtitleFontSize = (10 + 2 * (scale - 1)).clamp(10, 16);
|
||||
|
||||
return InkWell(
|
||||
onTap: isLoading ? null : onTap,
|
||||
@@ -801,9 +1031,12 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
child: Ink(
|
||||
padding: EdgeInsets.all(cardPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.paper,
|
||||
color: _equippedTheme?.surface ?? AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(cardRadius),
|
||||
border: Border.all(color: AppPalette.sage, width: 1),
|
||||
border: Border.all(
|
||||
color: _equippedTheme?.border ?? AppPalette.sage,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -812,7 +1045,7 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
Container(
|
||||
padding: EdgeInsets.all(iconBoxPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.linen,
|
||||
color: _equippedTheme?.surfaceAlt ?? AppPalette.linen,
|
||||
borderRadius: BorderRadius.circular(iconRadius),
|
||||
),
|
||||
child: Icon(icon, color: color, size: iconSize),
|
||||
@@ -831,7 +1064,9 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
style: TextStyle(
|
||||
fontSize: titleFontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppPalette.ink,
|
||||
color:
|
||||
_equippedTheme?.onBackground ??
|
||||
AppPalette.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -856,7 +1091,8 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: subtitleFontSize,
|
||||
color: AppPalette.ink.withValues(alpha: 0.55),
|
||||
color: (_equippedTheme?.onBackground ?? AppPalette.ink)
|
||||
.withValues(alpha: 0.55),
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
@@ -897,7 +1133,7 @@ class _DashboardMenuCard extends StatelessWidget {
|
||||
label: '로그아웃',
|
||||
onTap: onLogout,
|
||||
),
|
||||
const Divider(height: 1, color: AppPalette.sage),
|
||||
Divider(height: 1, color: AppPalette.sage),
|
||||
_MenuRow(
|
||||
icon: Icons.settings_outlined,
|
||||
label: '설정',
|
||||
|
||||
@@ -5,6 +5,9 @@ import 'dart:io' show Platform;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/nfc_pocket_checkin_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class NfcPocketCheckInScreen extends StatefulWidget {
|
||||
final String studentId; // 로그인된 학생 학번 (예: "2061")
|
||||
@@ -47,9 +50,9 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
void _handleMessage(String text, WatchMessageLevel level) {
|
||||
final Color color = switch (level) {
|
||||
WatchMessageLevel.info => Colors.blueGrey,
|
||||
WatchMessageLevel.success => Colors.green,
|
||||
WatchMessageLevel.warning => Colors.orange,
|
||||
WatchMessageLevel.error => Colors.red,
|
||||
WatchMessageLevel.success => AppPalette.success,
|
||||
WatchMessageLevel.warning => AppPalette.warning,
|
||||
WatchMessageLevel.error => AppPalette.danger,
|
||||
};
|
||||
_showSnackBar(text, color);
|
||||
}
|
||||
@@ -70,16 +73,20 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: Colors.red[50],
|
||||
backgroundColor: AppPalette.dangerBg,
|
||||
title: const Text(
|
||||
"무단 반출 감지",
|
||||
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
color: AppPalette.danger,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
content: Text(
|
||||
"[${pocketNumber ?? _controller.activePocketNumber}] 주머니에서 휴대폰이 꺼내진 것으로 감지되었습니다.\n담당 선생님께 알림이 전송되었습니다.",
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("확인"),
|
||||
),
|
||||
@@ -108,6 +115,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_controller.beginIosProximityWatch(pocketNumber);
|
||||
@@ -133,6 +141,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("확인"),
|
||||
),
|
||||
@@ -143,9 +152,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
|
||||
void _showSnackBar(String text, Color color) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(text), backgroundColor: color));
|
||||
AppNotice.show(context, text, color: color);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -159,14 +166,11 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
"자습실 NFC 출석체크",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
backgroundColor: const Color.fromARGB(255, 48, 48, 52),
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const TitlePill("자습실 NFC 출석체크"),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
@@ -179,8 +183,8 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
_controller.isProcessing ? Icons.sync : Icons.nfc,
|
||||
size: 100,
|
||||
color: _controller.isProcessing
|
||||
? Colors.orange
|
||||
: Colors.blueAccent,
|
||||
? AppPalette.warning
|
||||
: AppPalette.ink,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
@@ -192,7 +196,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _controller.isProcessing
|
||||
? Colors.orange
|
||||
? AppPalette.warning
|
||||
: Colors.black87,
|
||||
),
|
||||
),
|
||||
@@ -229,13 +233,13 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
height: 160,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.greenAccent.withValues(alpha: 0.15),
|
||||
border: Border.all(color: Colors.greenAccent, width: 3),
|
||||
color: AppPalette.success.withValues(alpha: 0.15),
|
||||
border: Border.all(color: AppPalette.success, width: 3),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.shield_rounded,
|
||||
size: 72,
|
||||
color: Colors.greenAccent,
|
||||
color: AppPalette.success,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/nfc_tag_writer_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class NfcTagWriterScreen extends StatefulWidget {
|
||||
const NfcTagWriterScreen({super.key});
|
||||
@@ -35,11 +37,11 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
|
||||
|
||||
void _showSnackBar(String text, bool isError) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(text),
|
||||
backgroundColor: isError ? Colors.red : Colors.green,
|
||||
),
|
||||
AppNotice.show(
|
||||
context,
|
||||
text,
|
||||
color: isError ? AppPalette.danger : AppPalette.success,
|
||||
icon: isError ? Icons.error_outline_rounded : Icons.check_circle_rounded,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,9 +54,11 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
appBar: AppBar(
|
||||
title: const Text("NFC 주머니 태그 쓰기"),
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const TitlePill("NFC 주머니 태그 쓰기"),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
@@ -75,7 +79,7 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
|
||||
Icon(
|
||||
isWriting ? Icons.nfc : Icons.edit_note_rounded,
|
||||
size: 80,
|
||||
color: isWriting ? Colors.orange : AppPalette.ink,
|
||||
color: isWriting ? AppPalette.warning : AppPalette.ink,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
// 🎨 프로필 커스텀 화면 (UI 전용). 공부시간으로 적립한 캐시로 프로필 카드 테마를
|
||||
// 구매/장착하는 상점. 서버 통신/상태는 lib/function/profile_shop_controller.dart가 담당한다.
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/profile_shop_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../theme/profile_themes.dart';
|
||||
import '../theme/theme_sync.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class ProfileCustomizeScreen extends StatefulWidget {
|
||||
final String studentId;
|
||||
final String studentName;
|
||||
|
||||
const ProfileCustomizeScreen({
|
||||
super.key,
|
||||
required this.studentId,
|
||||
required this.studentName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProfileCustomizeScreen> createState() => _ProfileCustomizeScreenState();
|
||||
}
|
||||
|
||||
class _ProfileCustomizeScreenState extends State<ProfileCustomizeScreen> {
|
||||
final ProfileShopController _controller = ProfileShopController();
|
||||
|
||||
// 🚧 첫 버전은 "기본 프로필 꾸미기"만 실제로 동작한다. 나머지 두 카테고리는
|
||||
// 와이어프레임 자리만 잡아둔 준비중 상태.
|
||||
String _selectedCategory = 'basic_profile';
|
||||
|
||||
// 👀 사기 전에도 눌러보면 바로 위 큰 타일이 그 테마로 바뀌어 보이는 미리보기 상태.
|
||||
// null = 아직 직접 고르지 않음(장착된 테마를 그대로 보여줌), -1 = "기본 모습"을 명시적으로 미리보는 중.
|
||||
int? _previewTemplateId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initialize();
|
||||
}
|
||||
|
||||
/// 상점 화면을 열 때마다 서버의 실제 장착 상태로 전역 테마를 다시 맞춰둔다
|
||||
/// (딴 데서 장착 상태가 바뀌었을 수도 있는 엣지 케이스 보호용).
|
||||
Future<void> _initialize() async {
|
||||
await _controller.loadCatalog(widget.studentId);
|
||||
if (!mounted) return;
|
||||
for (final t in _controller.templates) {
|
||||
if (t.equipped) {
|
||||
_syncGlobalTheme(t.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
_syncGlobalTheme(null);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 지금 큰 타일에 보여줄 템플릿을 고른다. 직접 미리보기를 누른 게 있으면 그걸,
|
||||
/// 없으면 실제 장착 중인 템플릿을, 그마저도 없으면 null(기본 모습)을 돌려준다.
|
||||
ShopTemplate? _resolvePreviewTemplate() {
|
||||
if (_previewTemplateId == -1) return null;
|
||||
if (_previewTemplateId != null) {
|
||||
for (final t in _controller.templates) {
|
||||
if (t.id == _previewTemplateId) return t;
|
||||
}
|
||||
}
|
||||
for (final t in _controller.templates) {
|
||||
if (t.equipped) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 🎨 지금 "체험하기/미리보기" 중인 테마로 화면 전체(배경/타일/버튼)를 물들인다.
|
||||
/// 아무것도 직접 고르지 않았으면 실제 장착된 테마가 그대로 나오고, "기본 모습"을
|
||||
/// 고르면 null이 되어 화면이 원래 모습으로 돌아간다 - 체험하기를 누르면 바로
|
||||
/// 커스텀 화면 전체에 반영되는 효과를 _resolvePreviewTemplate() 하나로 처리한다.
|
||||
ProfileTheme? get _displayTheme {
|
||||
final preview = _resolvePreviewTemplate();
|
||||
return preview == null ? null : profileThemes[preview.code];
|
||||
}
|
||||
|
||||
Future<void> _handlePurchase(ShopTemplate template) async {
|
||||
setState(() => _previewTemplateId = template.id);
|
||||
final (success, message) = await _controller.purchase(
|
||||
widget.studentId,
|
||||
template.id,
|
||||
);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(
|
||||
context,
|
||||
message,
|
||||
color: success ? AppPalette.success : AppPalette.danger,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleEquip(int? templateId) async {
|
||||
setState(() => _previewTemplateId = templateId ?? -1);
|
||||
final (success, message) = await _controller.equip(
|
||||
widget.studentId,
|
||||
templateId,
|
||||
);
|
||||
if (success) _syncGlobalTheme(templateId);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(
|
||||
context,
|
||||
message,
|
||||
color: success ? AppPalette.approved : AppPalette.danger,
|
||||
);
|
||||
}
|
||||
|
||||
/// 💡 커스텀창 안에서의 "미리보기"와 달리, 실제로 장착이 끝난 뒤에는 앱 전체
|
||||
/// (TitlePill, 다른 화면의 배경/카드색)도 같이 바뀌도록 전역에 반영한다.
|
||||
void _syncGlobalTheme(int? equippedTemplateId) {
|
||||
if (equippedTemplateId == null) {
|
||||
applyGlobalProfileTheme(null);
|
||||
return;
|
||||
}
|
||||
for (final t in _controller.templates) {
|
||||
if (t.id == equippedTemplateId) {
|
||||
applyGlobalProfileTheme(profileThemes[t.code]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: _displayTheme?.background ?? AppPalette.mist,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const TitlePill('프로필 꾸미기'),
|
||||
),
|
||||
body: ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => _controller.loadCatalog(widget.studentId),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
_buildCashBalance(),
|
||||
const SizedBox(height: 20),
|
||||
_buildCategoryTiles(),
|
||||
const SizedBox(height: 24),
|
||||
if (_controller.isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 40),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else
|
||||
_buildTemplateGrid(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCashBalance() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: _displayTheme?.secondary ?? AppPalette.ink,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.savings_rounded, color: Colors.white, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'보유 캐시',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${_controller.cashBalance} 캐시',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryTiles() {
|
||||
final previewTemplate = _resolvePreviewTemplate();
|
||||
// 📏 ListView(스크롤 영역) 안에서는 높이가 정해져 있지 않아 Row에
|
||||
// stretch 정렬만 주면 자식 크기 계산이 깨진다. SizedBox로 높이를 못박아둔다.
|
||||
return SizedBox(
|
||||
height: 210,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _CategoryTile(
|
||||
title: '기본 프로필 꾸미기',
|
||||
icon: Icons.person_rounded,
|
||||
selected: _selectedCategory == 'basic_profile',
|
||||
enabled: true,
|
||||
onTap: () => setState(() => _selectedCategory = 'basic_profile'),
|
||||
previewPrimary: previewTemplate?.primaryColor,
|
||||
previewSecondary: previewTemplate?.secondaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _CategoryTile(
|
||||
title: '랭킹 · 친구창 배경',
|
||||
subtitle: '준비중',
|
||||
icon: Icons.leaderboard_rounded,
|
||||
selected: false,
|
||||
enabled: false,
|
||||
onTap: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: _CategoryTile(
|
||||
title: '출석 · 그룹창 표시',
|
||||
subtitle: '준비중',
|
||||
icon: Icons.groups_rounded,
|
||||
selected: false,
|
||||
enabled: false,
|
||||
onTap: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTemplateGrid() {
|
||||
final templates = _controller.templates
|
||||
.where((t) => t.category == _selectedCategory)
|
||||
.toList();
|
||||
final hasEquippedTemplate = templates.any((t) => t.equipped);
|
||||
final previewId = _resolvePreviewTemplate()?.id; // null이면 "기본 모습"을 미리보는 중.
|
||||
|
||||
return GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1.15,
|
||||
children: [
|
||||
_DefaultTemplateTile(
|
||||
equipped: !hasEquippedTemplate,
|
||||
previewing: previewId == null,
|
||||
theme: _displayTheme,
|
||||
onEquip: () => _handleEquip(null),
|
||||
onPreview: () => setState(() => _previewTemplateId = -1),
|
||||
),
|
||||
for (final template in templates)
|
||||
_TemplateTile(
|
||||
template: template,
|
||||
canAfford: _controller.cashBalance >= template.price,
|
||||
previewing: previewId == template.id,
|
||||
theme: _displayTheme,
|
||||
onPurchase: () => _handlePurchase(template),
|
||||
onEquip: () => _handleEquip(template.id),
|
||||
onPreview: () => setState(() => _previewTemplateId = template.id),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryTile extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final IconData icon;
|
||||
final bool selected;
|
||||
final bool enabled;
|
||||
final VoidCallback? onTap;
|
||||
// 👀 미리보기 중인 테마 색상 - selected일 때만 의미가 있다 (기본 프로필 타일 전용).
|
||||
final Color? previewPrimary;
|
||||
final Color? previewSecondary;
|
||||
|
||||
const _CategoryTile({
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.icon,
|
||||
required this.selected,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
this.previewPrimary,
|
||||
this.previewSecondary,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool hasPreview = selected && previewPrimary != null;
|
||||
return Material(
|
||||
color: hasPreview
|
||||
? Colors.transparent
|
||||
: (selected
|
||||
? AppPalette.ink
|
||||
: (enabled ? AppPalette.paper : AppPalette.linen)),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
gradient: hasPreview
|
||||
? LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [previewPrimary!, previewSecondary!],
|
||||
)
|
||||
: null,
|
||||
border: Border.all(
|
||||
color: hasPreview
|
||||
? previewSecondary!
|
||||
: (selected ? AppPalette.ink : AppPalette.sage),
|
||||
width: hasPreview ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 22,
|
||||
color: selected
|
||||
? Colors.white
|
||||
: (enabled ? AppPalette.ink : Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
color: selected
|
||||
? Colors.white
|
||||
: (enabled ? AppPalette.ink : Colors.grey[600]),
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DefaultTemplateTile extends StatelessWidget {
|
||||
final bool equipped;
|
||||
final bool previewing;
|
||||
final ProfileTheme? theme;
|
||||
final VoidCallback onEquip;
|
||||
final VoidCallback onPreview;
|
||||
|
||||
const _DefaultTemplateTile({
|
||||
required this.equipped,
|
||||
required this.previewing,
|
||||
required this.theme,
|
||||
required this.onEquip,
|
||||
required this.onPreview,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onBackground = theme?.onBackground ?? AppPalette.ink;
|
||||
final borderColor = equipped
|
||||
? AppPalette.approved
|
||||
: (previewing ? onBackground : (theme?.border ?? AppPalette.sage));
|
||||
final borderWidth = equipped || previewing ? 2.0 : 1.0;
|
||||
|
||||
return Material(
|
||||
color: theme?.surface ?? AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: onPreview,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: borderColor, width: borderWidth),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.ink,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'기본 모습',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
color: onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (previewing && !equipped)
|
||||
Text(
|
||||
'미리보기',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: onBackground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: equipped
|
||||
? OutlinedButton(
|
||||
onPressed: null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppPalette.approved,
|
||||
side: const BorderSide(color: AppPalette.approved),
|
||||
),
|
||||
child: const Text('장착중'),
|
||||
)
|
||||
: OutlinedButton(
|
||||
onPressed: onEquip,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme?.accent,
|
||||
side: theme == null
|
||||
? null
|
||||
: BorderSide(color: theme!.accent),
|
||||
),
|
||||
child: const Text('장착하기'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TemplateTile extends StatelessWidget {
|
||||
final ShopTemplate template;
|
||||
final bool canAfford;
|
||||
final bool previewing;
|
||||
final ProfileTheme? theme;
|
||||
final VoidCallback onPurchase;
|
||||
final VoidCallback onEquip;
|
||||
final VoidCallback onPreview;
|
||||
|
||||
const _TemplateTile({
|
||||
required this.template,
|
||||
required this.canAfford,
|
||||
required this.previewing,
|
||||
required this.theme,
|
||||
required this.onPurchase,
|
||||
required this.onEquip,
|
||||
required this.onPreview,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onBackground = theme?.onBackground ?? AppPalette.ink;
|
||||
final borderColor = template.equipped
|
||||
? AppPalette.approved
|
||||
: (previewing ? onBackground : (theme?.border ?? AppPalette.sage));
|
||||
final borderWidth = template.equipped || previewing ? 2.0 : 1.0;
|
||||
|
||||
return Material(
|
||||
color: theme?.surface ?? AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: onPreview,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: borderColor, width: borderWidth),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [template.primaryColor, template.secondaryColor],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
template.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
color: onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (previewing && !template.equipped)
|
||||
Text(
|
||||
'미리보기',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: onBackground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${template.price} 캐시',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: onBackground.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (template.equipped)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppPalette.approved,
|
||||
side: const BorderSide(color: AppPalette.approved),
|
||||
),
|
||||
child: const Text('장착중'),
|
||||
),
|
||||
)
|
||||
else if (template.owned)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: onEquip,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme?.accent,
|
||||
side: theme == null
|
||||
? null
|
||||
: BorderSide(color: theme!.accent),
|
||||
),
|
||||
child: const Text('장착하기'),
|
||||
),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: onPreview,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme?.accent,
|
||||
side: theme == null
|
||||
? null
|
||||
: BorderSide(color: theme!.accent),
|
||||
),
|
||||
child: const Text('체험하기'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: canAfford ? onPurchase : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme?.secondary ?? AppPalette.ink,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: AppPalette.sage,
|
||||
),
|
||||
child: const Text('구매하기'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// 📅 백품타 출석 달력 화면 (UI 전용) - 개인 연속출석(스트릭) + 관리자 이벤트 달력.
|
||||
// "실시간 출석 현황"과 같은 패밀리룩(제목 알약 + 필 통계 + 둥근 카드)을 따른다.
|
||||
// 서버 통신/상태는 lib/function/study_calendar_controller.dart가 담당한다.
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/study_calendar_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../theme/theme_sync.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<StudyCalendarScreen> createState() => _StudyCalendarScreenState();
|
||||
}
|
||||
|
||||
class _StudyCalendarScreenState extends State<StudyCalendarScreen> {
|
||||
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<void> _onDayTap(DateTime day) async {
|
||||
final key = _dateKey(day);
|
||||
final event = _controller.events[key];
|
||||
|
||||
if (!widget.canManageEvents) {
|
||||
if (event != null) {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(event['title'] ?? ''),
|
||||
content: Text(
|
||||
(event['description'] ?? '').toString().isEmpty
|
||||
? '등록된 설명이 없어요.'
|
||||
: event['description'],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final titleController = TextEditingController(text: event?['title'] ?? '');
|
||||
final descController = TextEditingController(
|
||||
text: event?['description'] ?? '',
|
||||
);
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
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: AppPalette.danger),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (event != null) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor:
|
||||
globalProfileTheme.value?.accent ?? 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('저장'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 넓은 화면(웹 데스크톱)에서 내용이 양옆으로 늘어지지 않게 가운데 최대 440 폭으로 맞춘다.
|
||||
double _sidePad(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
return ((width - 440) / 2).clamp(16.0, double.infinity);
|
||||
}
|
||||
|
||||
@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: EdgeInsets.fromLTRB(
|
||||
_sidePad(context),
|
||||
0,
|
||||
_sidePad(context),
|
||||
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: 8),
|
||||
Center(
|
||||
child: Text(
|
||||
'하루 ${_controller.attendanceThresholdSeconds ~/ 60}분 '
|
||||
'이상 공부해야 그날 출석으로 인정돼요.',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
),
|
||||
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: 4,
|
||||
crossAxisSpacing: 4,
|
||||
childAspectRatio: 1.0,
|
||||
),
|
||||
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
|
||||
: AppPalette.warning,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
_legendDot(AppPalette.ink, '공부한 날'),
|
||||
const SizedBox(width: 16),
|
||||
_legendIcon(
|
||||
Icons.card_giftcard_rounded,
|
||||
AppPalette.warning,
|
||||
'이벤트',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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])),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// 🍅 뽀모도로 루틴 공유 화면 (UI 전용). 다른 학생들이 올린 공부/휴식 시간 조합을 보고
|
||||
// 그대로 적용하거나, 내가 쓰는 조합을 이름 붙여 공유할 수 있다.
|
||||
// 서버 통신/상태는 lib/function/study_preset_controller.dart가 담당한다.
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/study_preset_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class StudyPresetScreen extends StatefulWidget {
|
||||
final String studentId;
|
||||
final String studentName;
|
||||
final int initialStudyMinutes;
|
||||
final int initialBreakMinutes;
|
||||
|
||||
const StudyPresetScreen({
|
||||
super.key,
|
||||
required this.studentId,
|
||||
required this.studentName,
|
||||
required this.initialStudyMinutes,
|
||||
required this.initialBreakMinutes,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StudyPresetScreen> createState() => _StudyPresetScreenState();
|
||||
}
|
||||
|
||||
class _StudyPresetScreenState extends State<StudyPresetScreen> {
|
||||
late final StudyPresetController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = StudyPresetController(
|
||||
studentId: widget.studentId,
|
||||
studentName: widget.studentName,
|
||||
);
|
||||
_controller.init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _apply(dynamic preset) async {
|
||||
await _controller.markUsed(preset['id'] as int);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop({
|
||||
'studyMinutes': preset['studyMinutes'] as int,
|
||||
'breakMinutes': preset['breakMinutes'] as int,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _delete(int presetId) async {
|
||||
final (_, message) = await _controller.delete(presetId);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
|
||||
Future<void> _showShareDialog() async {
|
||||
final titleController = TextEditingController();
|
||||
int studyMinutes = widget.initialStudyMinutes;
|
||||
int breakMinutes = widget.initialBreakMinutes;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppPalette.paper,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (sheetContext, setSheetState) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'내 루틴 공유하기',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: titleController,
|
||||
maxLength: 30,
|
||||
decoration: InputDecoration(
|
||||
hintText: '루틴 이름 (예: 불태우는 금요일)',
|
||||
filled: true,
|
||||
fillColor: AppPalette.mist,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
_minuteRow(
|
||||
label: '공부 시간',
|
||||
minutes: studyMinutes,
|
||||
onChanged: (v) => setSheetState(() => studyMinutes = v),
|
||||
),
|
||||
_minuteRow(
|
||||
label: '휴식 시간',
|
||||
minutes: breakMinutes,
|
||||
onChanged: (v) => setSheetState(() => breakMinutes = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
onPressed: () async {
|
||||
final (success, message) = await _controller.share(
|
||||
title: titleController.text,
|
||||
studyMinutes: studyMinutes,
|
||||
breakMinutes: breakMinutes,
|
||||
);
|
||||
if (sheetContext.mounted) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
}
|
||||
if (!mounted) return;
|
||||
AppNotice.show(
|
||||
context,
|
||||
message,
|
||||
icon: success
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.error_outline_rounded,
|
||||
);
|
||||
},
|
||||
child: const Text('공유하기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _minuteRow({
|
||||
required String label,
|
||||
required int minutes,
|
||||
required ValueChanged<int> onChanged,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline_rounded),
|
||||
onPressed: minutes > 5 ? () => onChanged(minutes - 5) : null,
|
||||
),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: Text(
|
||||
'$minutes분',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline_rounded),
|
||||
onPressed: minutes < 120 ? () => onChanged(minutes + 5) : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) {
|
||||
final presets = _controller.presets;
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const TitlePill('뽀모도로 루틴 공유'),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
onPressed: _showShareDialog,
|
||||
icon: const Icon(Icons.ios_share_rounded),
|
||||
label: const Text('내 루틴 공유'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_sortChip('popular', '인기순'),
|
||||
const SizedBox(width: 8),
|
||||
_sortChip('recent', '최신순'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _controller.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: presets.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'아직 공유된 루틴이 없어요.\n첫 루틴을 공유해보세요.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
itemCount: presets.length,
|
||||
itemBuilder: (context, index) {
|
||||
final preset = presets[index];
|
||||
final bool isMine =
|
||||
preset['studentId'] == widget.studentId;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppPalette.sage),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
preset['title'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'공부 ${preset['studyMinutes']}분 · 휴식 ${preset['breakMinutes']}분',
|
||||
style: const TextStyle(
|
||||
color: AppPalette.ink,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${preset['studentName'] ?? ''} · 사용 ${preset['useCount'] ?? 0}회',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 11.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isMine)
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
color: Colors.grey,
|
||||
),
|
||||
onPressed: () =>
|
||||
_delete(preset['id'] as int),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
),
|
||||
onPressed: () => _apply(preset),
|
||||
child: const Text('적용'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sortChip(String value, String label) {
|
||||
final bool selected = _controller.sort == value;
|
||||
return ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: selected,
|
||||
onSelected: (_) => _controller.setSort(value),
|
||||
selectedColor: AppPalette.ink,
|
||||
labelStyle: TextStyle(
|
||||
color: selected ? Colors.white : AppPalette.ink,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// 🏆 학년별 공부시간 랭킹 화면 (UI 전용).
|
||||
// 서버 통신/상태는 lib/function/study_ranking_controller.dart가 담당한다.
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/study_ranking_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class StudyRankingScreen extends StatefulWidget {
|
||||
final int grade;
|
||||
final String myStudentId;
|
||||
|
||||
const StudyRankingScreen({
|
||||
super.key,
|
||||
required this.grade,
|
||||
required this.myStudentId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StudyRankingScreen> createState() => _StudyRankingScreenState();
|
||||
}
|
||||
|
||||
class _StudyRankingScreenState extends State<StudyRankingScreen> {
|
||||
late final StudyRankingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = StudyRankingController(grade: widget.grade);
|
||||
_controller.init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatHM(int totalSeconds) {
|
||||
final h = totalSeconds ~/ 3600;
|
||||
final m = (totalSeconds % 3600) ~/ 60;
|
||||
final s = totalSeconds % 60;
|
||||
if (h > 0) return '$h시간 $m분 $s초';
|
||||
if (m > 0) return '$m분 $s초';
|
||||
return '$s초';
|
||||
}
|
||||
|
||||
Color? _rankColor(int rank) {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
return const Color(0xFFC9A227); // 금
|
||||
case 2:
|
||||
return const Color(0xFF9AA0A6); // 은
|
||||
case 3:
|
||||
return const Color(0xFFB07A4B); // 동
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) {
|
||||
final ranking = _controller.ranking;
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: TitlePill(
|
||||
_controller.scope == 'grade'
|
||||
? '${widget.grade}학년 공부 랭킹'
|
||||
: '전체 공부 랭킹',
|
||||
),
|
||||
),
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
_scopeChip('grade', '${widget.grade}학년'),
|
||||
const SizedBox(width: 8),
|
||||
_scopeChip('all', '전체랭킹'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_periodChip('today', '오늘'),
|
||||
const SizedBox(width: 8),
|
||||
_periodChip('week', '이번주'),
|
||||
const SizedBox(width: 8),
|
||||
_periodChip('total', '전체'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _controller.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ranking.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'아직 기록이 없어요.\n공부 타이머를 눌러 1등에 도전해보세요.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
itemCount: ranking.length,
|
||||
itemBuilder: (context, index) {
|
||||
final r = ranking[index];
|
||||
final bool isMe =
|
||||
r['studentId'] == widget.myStudentId;
|
||||
final int rank = (r['rank'] as num).toInt();
|
||||
final medalColor = _rankColor(rank);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isMe
|
||||
? AppPalette.ink
|
||||
: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isMe
|
||||
? AppPalette.ink
|
||||
: AppPalette.sage,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32,
|
||||
child: medalColor != null
|
||||
? Icon(
|
||||
Icons.emoji_events_rounded,
|
||||
color: medalColor,
|
||||
size: 22,
|
||||
)
|
||||
: Text(
|
||||
'$rank',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isMe
|
||||
? Colors.white
|
||||
: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
r['name'] ?? '',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isMe
|
||||
? Colors.white
|
||||
: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatHM(
|
||||
(r['totalSeconds'] as num).toInt(),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isMe
|
||||
? Colors.white
|
||||
: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _periodChip(String value, String label) {
|
||||
final bool selected = _controller.period == value;
|
||||
return Expanded(
|
||||
child: ChoiceChip(
|
||||
label: Center(child: Text(label)),
|
||||
selected: selected,
|
||||
onSelected: (_) => _controller.setPeriod(value),
|
||||
selectedColor: AppPalette.ink,
|
||||
labelStyle: TextStyle(
|
||||
color: selected ? Colors.white : AppPalette.ink,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _scopeChip(String value, String label) {
|
||||
final bool selected = _controller.scope == value;
|
||||
return Expanded(
|
||||
child: ChoiceChip(
|
||||
label: Center(child: Text(label, style: const TextStyle(fontSize: 13))),
|
||||
selected: selected,
|
||||
onSelected: (_) => _controller.setScope(value),
|
||||
selectedColor: AppPalette.ink,
|
||||
backgroundColor: AppPalette.mist,
|
||||
labelStyle: TextStyle(
|
||||
color: selected ? Colors.white : Colors.grey[600],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
// ⏱️ 우리 학교 전용 "백품타" 공부 타이머 화면 (UI 전용).
|
||||
// 서버 통신/상태는 lib/function/study_timer_controller.dart가 담당한다.
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../function/browser_notification.dart';
|
||||
import '../function/study_timer_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../theme/theme_sync.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'launchpad_transition.dart';
|
||||
import 'study_preset_screen.dart';
|
||||
import 'study_ranking_screen.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class StudyTimerScreen extends StatefulWidget {
|
||||
final String studentId;
|
||||
final String studentName;
|
||||
final int? grade;
|
||||
|
||||
const StudyTimerScreen({
|
||||
super.key,
|
||||
required this.studentId,
|
||||
required this.studentName,
|
||||
this.grade,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StudyTimerScreen> createState() => _StudyTimerScreenState();
|
||||
}
|
||||
|
||||
class _StudyTimerScreenState extends State<StudyTimerScreen>
|
||||
with WidgetsBindingObserver {
|
||||
late final StudyTimerController _controller;
|
||||
Timer? _awayTimer;
|
||||
// ⌨️ 스페이스바로 시작/일시정지를 토글하기 위한 포커스 노드. 모달(뽀모도로 설정 등)이
|
||||
// 닫히면 포커스를 다시 여기로 돌려줘야 스페이스바가 계속 먹힌다.
|
||||
final FocusNode _keyboardFocusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = StudyTimerController(
|
||||
studentId: widget.studentId,
|
||||
studentName: widget.studentName,
|
||||
);
|
||||
_controller.onPhaseChanged = _handlePhaseChanged;
|
||||
_controller.init();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
void _handlePhaseChanged(String message) {
|
||||
if (!mounted) return;
|
||||
AppNotice.show(context, message, icon: Icons.timer_rounded);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// 🐛 [웹 탭 딜레이 버그 수정] 다른 창/탭에 갔다가 이 탭으로 돌아왔을 때(resumed) 밀린
|
||||
// 시간을 바로 따라잡는다 - 백그라운드 탭에서는 브라우저가 타이머를 느리게 돌리기 때문.
|
||||
_awayTimer?.cancel();
|
||||
_controller.onAppResumed();
|
||||
return;
|
||||
}
|
||||
// 🔔 타이머가 켜져 있는데 탭이 백그라운드로 가면(다른 탭/창/앱으로 이동), 20초 뒤에도
|
||||
// 계속 딴 데 가있으면 브라우저 알림으로 다시 공부하라고 알려준다.
|
||||
final bool isAway =
|
||||
state == AppLifecycleState.hidden ||
|
||||
state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.paused;
|
||||
if (isAway && _controller.isRunning) {
|
||||
_awayTimer?.cancel();
|
||||
_awayTimer = Timer(const Duration(seconds: 20), () {
|
||||
showBrowserNotification(
|
||||
'백품타 - 공부 중이었잖아요!',
|
||||
'타이머가 켜져 있어요. 다시 돌아와서 집중해볼까요?',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// 🔔 브라우저 탭은 그대로 두고 앱 안에서 다른 화면으로 이동하는 경우(뒤로가기 등)는
|
||||
// AppLifecycleState가 안 바뀌어서 위쪽 감지 로직이 못 잡는다. 그래서 이 화면을 나갈
|
||||
// 때 타이머가 돌아가는 중이었다면 여기서 직접 알림을 띄워준다.
|
||||
if (_controller.isRunning) {
|
||||
showBrowserNotification(
|
||||
'백품타 타이머가 계속 돌아가고 있어요',
|
||||
'이 화면을 나가도 공부 시간은 계속 기록돼요.',
|
||||
);
|
||||
}
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_awayTimer?.cancel();
|
||||
_controller.dispose();
|
||||
_keyboardFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// ⌨️ 스페이스바 입력 처리 - 멈춰있으면 시작, 돌아가고 있으면 일시정지,
|
||||
/// 일시정지 중이면 재개. 뽀모도로가 자동으로 공부↔휴식을 전환하는 중에는
|
||||
/// 화면에서도 수동 일시정지/재개 버튼을 숨기므로 스페이스바도 똑같이 막는다.
|
||||
void _handleSpacePress() {
|
||||
if (_controller.isBusy) return;
|
||||
if (_controller.isIdle) {
|
||||
requestNotificationPermission();
|
||||
_runAction(_controller.start);
|
||||
return;
|
||||
}
|
||||
if (_controller.pomodoroEnabled && _controller.phase != null) {
|
||||
return;
|
||||
}
|
||||
if (_controller.isRunning) {
|
||||
_runAction(_controller.pause);
|
||||
} else if (_controller.isPaused) {
|
||||
_runAction(_controller.resume);
|
||||
}
|
||||
}
|
||||
|
||||
// 🛍️ 장착한 템플릿의 강조색 - 시작/재개처럼 "중립" 상태의 버튼/텍스트에만 쓴다.
|
||||
// 일시정지(warning)/종료(danger) 같은 경고성 색은 의미가 있어서 그대로 둔다.
|
||||
Color get _accentColor => globalProfileTheme.value?.accent ?? AppPalette.ink;
|
||||
|
||||
String _formatHMS(int totalSeconds) {
|
||||
final h = totalSeconds ~/ 3600;
|
||||
final m = (totalSeconds % 3600) ~/ 60;
|
||||
final s = totalSeconds % 60;
|
||||
final hh = h.toString().padLeft(2, '0');
|
||||
final mm = m.toString().padLeft(2, '0');
|
||||
final ss = s.toString().padLeft(2, '0');
|
||||
return '$hh:$mm:$ss';
|
||||
}
|
||||
|
||||
String _formatMinSec(int totalSeconds) {
|
||||
final s = totalSeconds < 0 ? 0 : totalSeconds;
|
||||
final m = s ~/ 60;
|
||||
final sec = s % 60;
|
||||
return '$m:${sec.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _statusLabel(bool isRunning, bool isPaused) {
|
||||
if (_controller.pomodoroEnabled && _controller.phase != null) {
|
||||
return _controller.phase == PomodoroPhase.study
|
||||
? '뽀모도로 - 공부 중이에요'
|
||||
: '뽀모도로 - 쉬는 중이에요';
|
||||
}
|
||||
if (isRunning) return '지금 공부 중이에요';
|
||||
if (isPaused) return '일시정지 중이에요';
|
||||
return '공부를 시작해볼까요?';
|
||||
}
|
||||
|
||||
String _formatHM(int totalSeconds) {
|
||||
final h = totalSeconds ~/ 3600;
|
||||
final m = (totalSeconds % 3600) ~/ 60;
|
||||
final s = totalSeconds % 60;
|
||||
if (h > 0) return '$h시간 $m분 $s초';
|
||||
if (m > 0) return '$m분 $s초';
|
||||
return '$s초';
|
||||
}
|
||||
|
||||
Future<void> _runAction(
|
||||
Future<(bool success, String message)> Function() action,
|
||||
) async {
|
||||
final (success, message) = await action();
|
||||
if (!mounted) return;
|
||||
AppNotice.show(
|
||||
context,
|
||||
message,
|
||||
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showPomodoroSettings() async {
|
||||
bool enabled = _controller.pomodoroEnabled;
|
||||
int studyMinutes = _controller.studyMinutes;
|
||||
int breakMinutes = _controller.breakMinutes;
|
||||
final bool canEdit = _controller.isIdle;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppPalette.paper,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (sheetContext, setSheetState) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'뽀모도로 모드',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: enabled,
|
||||
activeThumbColor: AppPalette.ink,
|
||||
onChanged: canEdit
|
||||
? (v) => setSheetState(() => enabled = v)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Text(
|
||||
'공부/휴식 시간을 정해두면, 공부 시간이 끝났을 때 자동으로\n일시정지되고 휴식이 끝나면 자동으로 다시 시작해요.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12.5),
|
||||
),
|
||||
if (!canEdit) ...[
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'타이머가 멈춰있을 때만 설정을 바꿀 수 있어요.',
|
||||
style: TextStyle(
|
||||
color: AppPalette.danger,
|
||||
fontSize: 12.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
_minuteStepper(
|
||||
label: '공부 시간',
|
||||
minutes: studyMinutes,
|
||||
enabled: canEdit,
|
||||
onChanged: (v) => setSheetState(() => studyMinutes = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_minuteStepper(
|
||||
label: '휴식 시간',
|
||||
minutes: breakMinutes,
|
||||
enabled: canEdit,
|
||||
onChanged: (v) => setSheetState(() => breakMinutes = v),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.groups_rounded, size: 18),
|
||||
label: const Text('친구들이 공유한 루틴 보기 / 공유하기'),
|
||||
onPressed: () async {
|
||||
final result = await pushLaunchpad<Map<String, int>>(
|
||||
sheetContext,
|
||||
(context) => StudyPresetScreen(
|
||||
studentId: widget.studentId,
|
||||
studentName: widget.studentName,
|
||||
initialStudyMinutes: studyMinutes,
|
||||
initialBreakMinutes: breakMinutes,
|
||||
),
|
||||
);
|
||||
if (result == null) return;
|
||||
if (!canEdit) {
|
||||
if (sheetContext.mounted) {
|
||||
AppNotice.show(
|
||||
sheetContext,
|
||||
'타이머가 멈춰있을 때만 루틴을 적용할 수 있어요.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSheetState(() {
|
||||
studyMinutes = result['studyMinutes']!;
|
||||
breakMinutes = result['breakMinutes']!;
|
||||
enabled = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
onPressed: !canEdit
|
||||
? null
|
||||
: () async {
|
||||
await _controller.setPomodoroSettings(
|
||||
enabled: enabled,
|
||||
studyMinutes: studyMinutes,
|
||||
breakMinutes: breakMinutes,
|
||||
);
|
||||
if (sheetContext.mounted) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
}
|
||||
},
|
||||
child: const Text('저장'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
// 모달이 닫히면 스페이스바 토글이 다시 먹히도록 포커스를 돌려준다.
|
||||
_keyboardFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
Widget _minuteStepper({
|
||||
required String label,
|
||||
required int minutes,
|
||||
required bool enabled,
|
||||
required ValueChanged<int> onChanged,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline_rounded),
|
||||
onPressed: enabled && minutes > 5
|
||||
? () => onChanged(minutes - 5)
|
||||
: null,
|
||||
),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: Text(
|
||||
'$minutes분',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline_rounded),
|
||||
onPressed: enabled && minutes < 120
|
||||
? () => onChanged(minutes + 5)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _openRanking() {
|
||||
if (widget.grade == null) {
|
||||
AppNotice.show(context, '학년 정보가 없어 랭킹을 볼 수 없어요.');
|
||||
return;
|
||||
}
|
||||
pushLaunchpad(
|
||||
context,
|
||||
(context) => StudyRankingScreen(
|
||||
grade: widget.grade!,
|
||||
myStudentId: widget.studentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) {
|
||||
final bool isRunning = _controller.isRunning;
|
||||
final bool isPaused = _controller.isPaused;
|
||||
return Focus(
|
||||
focusNode: _keyboardFocusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.space) {
|
||||
_handleSpacePress();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const TitlePill('백품타'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.tune_rounded),
|
||||
tooltip: '뽀모도로 설정',
|
||||
onPressed: _showPomodoroSettings,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.leaderboard_rounded),
|
||||
tooltip: '학년 랭킹',
|
||||
onPressed: _openRanking,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _controller.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_statusLabel(isRunning, isPaused),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
if (_controller.pomodoroEnabled &&
|
||||
_controller.phase != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${_controller.phase == PomodoroPhase.study ? '다음 휴식까지' : '다음 공부까지'} '
|
||||
'${_formatMinSec(_controller.phaseSecondsLeft)} · '
|
||||
'완료 ${_controller.completedCycles}회',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_formatHMS(_controller.liveElapsedSeconds),
|
||||
style: TextStyle(
|
||||
fontSize: 52,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isPaused ? Colors.grey : _accentColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
_buildActionButtons(isRunning, isPaused),
|
||||
const SizedBox(height: 40),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_statBox(
|
||||
'오늘',
|
||||
_formatHM(_controller.todaySecondsLive),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_statBox(
|
||||
'이번주',
|
||||
_formatHM(_controller.weekSecondsLive),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_statBox(
|
||||
'전체',
|
||||
_formatHM(_controller.totalSecondsLive),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(bool isRunning, bool isPaused) {
|
||||
if (!isRunning && !isPaused) {
|
||||
return _circleButton(
|
||||
size: 140,
|
||||
icon: Icons.play_arrow_rounded,
|
||||
label: _controller.pomodoroEnabled ? '뽀모도로 시작' : '공부 시작',
|
||||
color: _accentColor,
|
||||
onTap: () {
|
||||
requestNotificationPermission();
|
||||
_runAction(_controller.start);
|
||||
},
|
||||
);
|
||||
}
|
||||
if (_controller.pomodoroEnabled && _controller.phase != null) {
|
||||
// 뽀모도로 자동 전환 중에는 일시정지/재개는 자동으로 일어나니 종료 버튼만 보여준다.
|
||||
return _circleButton(
|
||||
size: 140,
|
||||
icon: Icons.stop_rounded,
|
||||
label: '공부 종료',
|
||||
color: AppPalette.danger,
|
||||
onTap: () => _runAction(_controller.stop),
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_circleButton(
|
||||
size: 110,
|
||||
icon: isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded,
|
||||
label: isPaused ? '재개' : '일시정지',
|
||||
color: isPaused ? _accentColor : AppPalette.warning,
|
||||
onTap: () =>
|
||||
_runAction(isPaused ? _controller.resume : _controller.pause),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
_circleButton(
|
||||
size: 110,
|
||||
icon: Icons.stop_rounded,
|
||||
label: '공부 종료',
|
||||
color: AppPalette.danger,
|
||||
onTap: () => _runAction(_controller.stop),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _circleButton({
|
||||
required double size,
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required Color color,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: _controller.isBusy ? null : onTap,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: _controller.isBusy
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: Colors.white, size: size > 120 ? 36 : 28),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: size > 120 ? 14 : 12.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statBox(String label, String value) {
|
||||
return Container(
|
||||
width: 108,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppPalette.sage),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+466
-227
@@ -3,6 +3,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/teacher_attendance_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'launchpad_transition.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화)
|
||||
@@ -29,13 +32,11 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 컨트롤러 액션을 실행하고, 결과 메시지를 스낵바로 보여준다.
|
||||
/// 컨트롤러 액션을 실행하고, 결과 메시지를 알약형 알림으로 보여준다.
|
||||
Future<void> _runAction(Future<String> Function() action) async {
|
||||
final message = await action();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
|
||||
Future<void> _showAttendanceTimeDialog() async {
|
||||
@@ -74,6 +75,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ElevatedButton(
|
||||
autofocus: true,
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_runAction(() => _controller.resetTestPermissions());
|
||||
@@ -97,7 +99,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
title: Text('$studentName 학생 반출 허용'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (value) {
|
||||
final minutes = int.tryParse(value.trim()) ?? 5;
|
||||
Navigator.pop(context);
|
||||
_runAction(() => _controller.allowRemoval(studentId, minutes));
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: '허용 시간 (분)',
|
||||
border: OutlineInputBorder(),
|
||||
@@ -138,7 +147,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (value) {
|
||||
final minutes = int.tryParse(value.trim()) ?? 10;
|
||||
Navigator.pop(context);
|
||||
_runAction(() => _controller.setPermissionWindow(minutes));
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: '지금부터 허용 시간 (분)',
|
||||
border: OutlineInputBorder(),
|
||||
@@ -179,6 +195,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ElevatedButton(
|
||||
autofocus: true,
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_runAction(() => _controller.dismissAll());
|
||||
@@ -204,66 +221,20 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
// 🪶 검정 배너 대신, 제목 알약은 "전체 학생 수" 버튼 바로 위(사이드바/모바일 요약 위)에 둔다.
|
||||
// 나머지 기능은 전부 "자습실 시간 설정 메뉴"(런치패드 스타일 오버레이) 하나로 모은다.
|
||||
// 폰에서는 사이드바가 없으니 여기 아이콘으로도 같은 메뉴를 열 수 있게 둔다.
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'실시간 출석 현황',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: (_controller.isLoading || _controller.isRefreshing)
|
||||
? null
|
||||
: _controller.manualRefresh,
|
||||
icon: _controller.isRefreshing
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
icon: const Icon(Icons.more_horiz_rounded),
|
||||
tooltip: '자습실 시간 설정 메뉴',
|
||||
onPressed: _showTimeSettingsMenu,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.refresh_rounded),
|
||||
tooltip: '새로고침',
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _showAttendanceTimeDialog,
|
||||
icon: const Icon(
|
||||
Icons.access_time_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
label: Text(
|
||||
_controller.attendanceTime != null
|
||||
? '출석시간 ${_controller.attendanceTime}'
|
||||
: '자습실 출석시간 설정',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _showPermissionWindowDialog,
|
||||
icon: const Icon(Icons.timer_outlined, color: Colors.white),
|
||||
label: const Text(
|
||||
'반출 허용 시간 설정',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _showDismissalConfirmDialog,
|
||||
icon: const Icon(Icons.school_rounded, color: Colors.white),
|
||||
label: const Text('하교', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _showTestResetConfirmDialog,
|
||||
icon: const Icon(
|
||||
Icons.bug_report_outlined,
|
||||
color: Colors.white70,
|
||||
),
|
||||
tooltip: '테스트용: 허용시간 초기화',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
body: _controller.isLoading
|
||||
@@ -296,9 +267,30 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
final groups = _controller.studentsByGrade;
|
||||
return Column(
|
||||
children: [
|
||||
_buildSummaryCards(total, checkedIn, absent),
|
||||
const SizedBox(height: 12),
|
||||
const TitlePill('실시간 출석 현황'),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _sidebarStatPill('전체 학생 수', total, 'ALL')),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _sidebarStatPill('출석 학생 수', checkedIn, 'CHECKED_IN'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _sidebarStatPill('미출석 학생수', absent, 'ABSENT')),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: _sidebarFilterButton(),
|
||||
),
|
||||
_buildPermissionBanner(),
|
||||
_buildFilterChips(),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: groups.isEmpty
|
||||
? const Center(
|
||||
@@ -384,8 +376,10 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
final bool isPending = student['attendanceStatus'] == 'PENDING';
|
||||
final bool isComplete = student['attendanceStatus'] == 'COMPLETE';
|
||||
final Color statusColor = hasViolation
|
||||
? Colors.red
|
||||
: (isPending ? Colors.orange : (isComplete ? Colors.blue : Colors.red));
|
||||
? AppPalette.danger
|
||||
: (isPending
|
||||
? AppPalette.warning
|
||||
: (isComplete ? AppPalette.approved : AppPalette.danger));
|
||||
final String statusLine = hasViolation
|
||||
? '무단반출 (${student['violationTime']})'
|
||||
: (isPending
|
||||
@@ -397,10 +391,10 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: hasViolation ? Colors.red[50] : AppPalette.paper,
|
||||
color: hasViolation ? AppPalette.dangerBg : AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: hasViolation ? Colors.red[300]! : AppPalette.sage,
|
||||
color: hasViolation ? AppPalette.danger : AppPalette.sage,
|
||||
width: hasViolation ? 1.5 : 1,
|
||||
),
|
||||
boxShadow: [
|
||||
@@ -475,10 +469,10 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: hasViolation
|
||||
? Colors.red[700]
|
||||
? AppPalette.danger
|
||||
: (isPending
|
||||
? Colors.orange[800]
|
||||
: (isComplete ? Colors.grey[600] : Colors.red[400])),
|
||||
? AppPalette.warning
|
||||
: (isComplete ? Colors.grey[600] : AppPalette.danger)),
|
||||
fontSize: 11.5,
|
||||
fontWeight: hasViolation ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
@@ -494,7 +488,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
student['studentName'],
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red[600],
|
||||
backgroundColor: AppPalette.danger,
|
||||
foregroundColor: Colors.white,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
@@ -509,51 +503,24 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
|
||||
// 왼쪽에 요약/필터 사이드바, 오른쪽에 좌석표처럼 촘촘한 학생 칸 그리드를 둬서
|
||||
// 80명이 넘는 인원도 스크롤을 최소화하고 한눈에 볼 수 있게 한다.
|
||||
// -----------------------------------------------------------------------
|
||||
Widget _buildDesktopBody(int total, int checkedIn, int absent) {
|
||||
final groups = _controller.studentsByGrade;
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1100),
|
||||
child: Padding(
|
||||
final students = _controller.filteredStudents;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _statTile(
|
||||
"전체 학생",
|
||||
"$total명",
|
||||
Icons.groups_rounded,
|
||||
Colors.blueGrey,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _statTile(
|
||||
"출석 완료",
|
||||
"$checkedIn명",
|
||||
Icons.check_circle_rounded,
|
||||
Colors.green,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _statTile(
|
||||
"미출석",
|
||||
"$absent명",
|
||||
Icons.error_rounded,
|
||||
Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildPermissionBanner(),
|
||||
_buildFilterChips(),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDesktopSidebar(total, checkedIn, absent),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
@@ -568,105 +535,324 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: groups.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'해당하는 학생이 없습니다.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
: _buildGradeGroupedList(
|
||||
groups,
|
||||
padding: const EdgeInsets.all(20),
|
||||
),
|
||||
child: _buildDenseSeatGrid(students),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🧾 왼쪽 요약/필터 사이드바. 인원 수 캡슐을 누르면 그 필터가 바로 적용된다.
|
||||
Widget _buildDesktopSidebar(int total, int checkedIn, int absent) {
|
||||
return SizedBox(
|
||||
width: 200,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(child: const TitlePill('실시간 출석 현황')),
|
||||
const SizedBox(height: 16),
|
||||
_sidebarStatPill('전체 학생 수', total, 'ALL'),
|
||||
const SizedBox(height: 10),
|
||||
_sidebarStatPill('출석 학생 수', checkedIn, 'CHECKED_IN'),
|
||||
const SizedBox(height: 10),
|
||||
_sidebarStatPill('미출석 학생수', absent, 'ABSENT'),
|
||||
const SizedBox(height: 20),
|
||||
_sidebarFilterButton(),
|
||||
const SizedBox(height: 10),
|
||||
_sidebarTimeSettingsButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sidebarTimeSettingsButton() {
|
||||
return InkWell(
|
||||
onTap: _showTimeSettingsMenu,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(color: AppPalette.sage),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.access_time_rounded,
|
||||
size: 18,
|
||||
color: AppPalette.ink,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Flexible(
|
||||
child: Text(
|
||||
'자습실 시간 설정 메뉴',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statTile(String label, String value, IconData icon, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
Widget _sidebarStatPill(String label, int count, String filterValue) {
|
||||
final bool selected = _controller.filterType == filterValue;
|
||||
return InkWell(
|
||||
onTap: () => _controller.setFilter(filterValue),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
color: selected ? AppPalette.ink : AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: selected ? AppPalette.ink : AppPalette.sage,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 26),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: Colors.grey[600], fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: selected ? Colors.white70 : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 📊 상단 요약 카드 뷰 (전체 / 출석 완료 / 미출석)
|
||||
Widget _buildSummaryCards(int total, int checkedIn, int absent) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: AppPalette.ink,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_summaryCard("전체", "$total명", Colors.white70),
|
||||
_summaryCard("출석 완료", "$checkedIn명", Colors.greenAccent),
|
||||
_summaryCard("미출석", "$absent명", Colors.redAccent),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summaryCard(String title, String count, Color color) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(title, style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
count,
|
||||
'$count명',
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 20,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: selected ? Colors.white : AppPalette.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sidebarFilterButton() {
|
||||
return InkWell(
|
||||
onTap: _showGradeFilterMenu,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(color: AppPalette.sage),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.filter_list_rounded,
|
||||
size: 18,
|
||||
color: AppPalette.ink,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_gradeFilterLabel(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _gradeFilterLabel() {
|
||||
switch (_controller.gradeFilter) {
|
||||
case '1':
|
||||
return '1학년만';
|
||||
case '2':
|
||||
return '2학년만';
|
||||
case '3':
|
||||
return '3학년만';
|
||||
default:
|
||||
return '학생 나눠보기';
|
||||
}
|
||||
}
|
||||
|
||||
// 🚀 "학생 나눠보기" 버튼 — 학년 필터를 런치패드 스타일 메뉴로 고른다.
|
||||
Future<void> _showGradeFilterMenu() async {
|
||||
final grade = await showLaunchpadMenu<String>(
|
||||
context: context,
|
||||
builder: (context) => Center(
|
||||
child: Material(
|
||||
color: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
elevation: 8,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'학년 나눠보기',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_gradeChoice(context, 'ALL', '전체'),
|
||||
_gradeChoice(context, '1', '1학년'),
|
||||
_gradeChoice(context, '2', '2학년'),
|
||||
_gradeChoice(context, '3', '3학년'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (grade != null) _controller.setGradeFilter(grade);
|
||||
}
|
||||
|
||||
Widget _gradeChoice(BuildContext dialogContext, String value, String label) {
|
||||
return ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: _controller.gradeFilter == value,
|
||||
onSelected: (_) => Navigator.pop(dialogContext, value),
|
||||
);
|
||||
}
|
||||
|
||||
// 🚀 "자습실 시간 설정 메뉴" — 새로고침/출석시간/반출허용/하교/테스트리셋을
|
||||
// 전부 여기 하나로 모아서, 메인 대시보드의 로그아웃/설정 메뉴와 똑같은
|
||||
// 런치패드 스타일(배경 블러 유지) 오버레이로 띄운다.
|
||||
Future<void> _showTimeSettingsMenu() async {
|
||||
final action = await showLaunchpadMenu<String>(
|
||||
context: context,
|
||||
builder: (context) => Center(
|
||||
child: _TimeSettingsMenuCard(
|
||||
isRefreshing: _controller.isRefreshing,
|
||||
attendanceTimeLabel: _controller.attendanceTime != null
|
||||
? '출석시간 ${_controller.attendanceTime}'
|
||||
: '자습실 출석시간 설정',
|
||||
onPick: (value) => Navigator.pop(context, value),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!mounted || action == null) return;
|
||||
switch (action) {
|
||||
case 'refresh':
|
||||
_controller.manualRefresh();
|
||||
break;
|
||||
case 'attendance_time':
|
||||
_showAttendanceTimeDialog();
|
||||
break;
|
||||
case 'permission_window':
|
||||
_showPermissionWindowDialog();
|
||||
break;
|
||||
case 'dismissal':
|
||||
_showDismissalConfirmDialog();
|
||||
break;
|
||||
case 'test_reset':
|
||||
_showTestResetConfirmDialog();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 🪑 좌석표처럼 촘촘하게 학생 한 명씩을 작은 칸에 담는 그리드.
|
||||
Widget _buildDenseSeatGrid(List<Map<String, dynamic>> students) {
|
||||
if (students.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('해당하는 학생이 없습니다.', style: TextStyle(color: Colors.grey)),
|
||||
);
|
||||
}
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 92,
|
||||
mainAxisExtent: 62,
|
||||
crossAxisSpacing: 6,
|
||||
mainAxisSpacing: 6,
|
||||
),
|
||||
itemCount: students.length,
|
||||
itemBuilder: (context, index) => _buildSeatCell(students[index], index),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSeatCell(Map<String, dynamic> student, int index) {
|
||||
final bool hasViolation = student['hasActiveViolation'] == true;
|
||||
final bool isPending = student['attendanceStatus'] == 'PENDING';
|
||||
final bool isComplete = student['attendanceStatus'] == 'COMPLETE';
|
||||
final Color background = hasViolation
|
||||
? AppPalette.dangerBg
|
||||
: isComplete
|
||||
? AppPalette.approvedBg
|
||||
: isPending
|
||||
? AppPalette.warningBg
|
||||
: AppPalette.paper;
|
||||
final Color border = hasViolation
|
||||
? AppPalette.danger
|
||||
: isComplete
|
||||
? AppPalette.approved
|
||||
: isPending
|
||||
? AppPalette.warning
|
||||
: AppPalette.sage;
|
||||
|
||||
return InkWell(
|
||||
onTap: hasViolation
|
||||
? () => _showAllowDialog(student['studentId'], student['studentName'])
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'[${index + 1}] ${student['studentId']}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 8, color: Colors.grey[500]),
|
||||
),
|
||||
Text(
|
||||
'${student['studentName']}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (hasViolation)
|
||||
Text(
|
||||
'무단반출',
|
||||
style: TextStyle(
|
||||
fontSize: 7.5,
|
||||
color: AppPalette.danger,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -681,19 +867,19 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber[100],
|
||||
color: AppPalette.warningBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.amber[400]!),
|
||||
border: Border.all(color: AppPalette.warning),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.lock_open_rounded, color: Colors.amber[800], size: 20),
|
||||
Icon(Icons.lock_open_rounded, color: AppPalette.warning, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'지금 전체 반출 허용 중입니다 ($until 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.',
|
||||
style: TextStyle(
|
||||
color: Colors.amber[900],
|
||||
color: AppPalette.warning,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12.5,
|
||||
),
|
||||
@@ -703,63 +889,116 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자)
|
||||
Widget _buildFilterChips() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
// 🚀 "자습실 시간 설정 메뉴" 카드 — 메인 대시보드 로그아웃/설정 메뉴와 같은 톤으로 통일.
|
||||
class _TimeSettingsMenuCard extends StatelessWidget {
|
||||
final bool isRefreshing;
|
||||
final String attendanceTimeLabel;
|
||||
final ValueChanged<String> onPick;
|
||||
|
||||
const _TimeSettingsMenuCard({
|
||||
required this.isRefreshing,
|
||||
required this.attendanceTimeLabel,
|
||||
required this.onPick,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppPalette.paper,
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: IntrinsicWidth(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 220),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text("전체"),
|
||||
selected: _controller.filterType == "ALL",
|
||||
onSelected: (_) => _controller.setFilter("ALL"),
|
||||
_TimeMenuRow(
|
||||
icon: isRefreshing ? null : Icons.refresh_rounded,
|
||||
label: '새로고침',
|
||||
onTap: () => onPick('refresh'),
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text("출석자"),
|
||||
selected: _controller.filterType == "CHECKED_IN",
|
||||
onSelected: (_) => _controller.setFilter("CHECKED_IN"),
|
||||
Divider(height: 1, color: AppPalette.sage),
|
||||
_TimeMenuRow(
|
||||
icon: Icons.access_time_rounded,
|
||||
label: attendanceTimeLabel,
|
||||
onTap: () => onPick('attendance_time'),
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text("미출석자"),
|
||||
selected: _controller.filterType == "ABSENT",
|
||||
onSelected: (_) => _controller.setFilter("ABSENT"),
|
||||
Divider(height: 1, color: AppPalette.sage),
|
||||
_TimeMenuRow(
|
||||
icon: Icons.timer_outlined,
|
||||
label: '반출 허용 시간 설정',
|
||||
onTap: () => onPick('permission_window'),
|
||||
),
|
||||
Divider(height: 1, color: AppPalette.sage),
|
||||
_TimeMenuRow(
|
||||
icon: Icons.school_rounded,
|
||||
label: '하교 처리',
|
||||
onTap: () => onPick('dismissal'),
|
||||
),
|
||||
Divider(height: 1, color: AppPalette.sage),
|
||||
_TimeMenuRow(
|
||||
icon: Icons.bug_report_outlined,
|
||||
label: '테스트용: 허용시간 초기화',
|
||||
dim: true,
|
||||
onTap: () => onPick('test_reset'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text("학년 전체"),
|
||||
selected: _controller.gradeFilter == "ALL",
|
||||
onSelected: (_) => _controller.setGradeFilter("ALL"),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text("1학년"),
|
||||
selected: _controller.gradeFilter == "1",
|
||||
onSelected: (_) => _controller.setGradeFilter("1"),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text("2학년"),
|
||||
selected: _controller.gradeFilter == "2",
|
||||
onSelected: (_) => _controller.setGradeFilter("2"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimeMenuRow extends StatelessWidget {
|
||||
final IconData? icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool dim;
|
||||
|
||||
const _TimeMenuRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.dim = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: icon == null
|
||||
? const CircularProgressIndicator(strokeWidth: 2)
|
||||
: Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: dim ? Colors.grey : AppPalette.ink,
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text("3학년"),
|
||||
selected: _controller.gradeFilter == "3",
|
||||
onSelected: (_) => _controller.setGradeFilter("3"),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: dim ? Colors.grey[600] : AppPalette.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
// 📍 선생님 호출 화면(학생용) (UI 전용). "실시간 출석 현황" 화면과 같은 패밀리룩
|
||||
// (제목 알약 + 필 모양 통계/필터 + 그림자 있는 둥근 타일)을 따른다.
|
||||
// 서버 통신/상태는 lib/function/teacher_call_controller.dart가 담당한다.
|
||||
import 'package:flutter/material.dart';
|
||||
import '../data/teacher_call_schedule.dart';
|
||||
import '../function/teacher_call_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class TeacherCallScreen extends StatefulWidget {
|
||||
final String studentId;
|
||||
final String studentName;
|
||||
|
||||
const TeacherCallScreen({
|
||||
super.key,
|
||||
required this.studentId,
|
||||
required this.studentName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TeacherCallScreen> createState() => _TeacherCallScreenState();
|
||||
}
|
||||
|
||||
class _TeacherCallScreenState extends State<TeacherCallScreen> {
|
||||
late final TeacherCallController _controller;
|
||||
String? _selectedTeacherId;
|
||||
String? _selectedTeacherName;
|
||||
String _selectedPurpose = kCallPurposes.first;
|
||||
final _customPurposeController = TextEditingController();
|
||||
bool _useCustomPurpose = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TeacherCallController(
|
||||
studentId: widget.studentId,
|
||||
studentName: widget.studentName,
|
||||
);
|
||||
_controller.init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_customPurposeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _call() async {
|
||||
if (_selectedTeacherId == null) {
|
||||
AppNotice.show(context, '선생님을 선택해 주세요.');
|
||||
return;
|
||||
}
|
||||
final purpose = _useCustomPurpose
|
||||
? _customPurposeController.text.trim()
|
||||
: _selectedPurpose;
|
||||
if (purpose.isEmpty) {
|
||||
AppNotice.show(context, '방문 목적을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
final (success, message) = await _controller.callTeacher(
|
||||
teacherId: _selectedTeacherId!,
|
||||
purpose: purpose,
|
||||
);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(
|
||||
context,
|
||||
message,
|
||||
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||
);
|
||||
}
|
||||
|
||||
/// 넓은 화면(웹 데스크톱)에서 내용이 양옆으로 늘어지지 않게 가운데 최대 560 폭으로 맞춘다.
|
||||
double _sidePad(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
return ((width - 560) / 2).clamp(16.0, double.infinity);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) {
|
||||
final teachers = _controller.teachers;
|
||||
final inClassCount = teachers
|
||||
.where((t) => _controller.isInClassNow(t['name'] ?? ''))
|
||||
.length;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
),
|
||||
body: _controller.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _controller.refresh,
|
||||
child: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
_sidePad(context),
|
||||
0,
|
||||
_sidePad(context),
|
||||
16,
|
||||
),
|
||||
children: [
|
||||
const Center(child: TitlePill('선생님 호출')),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _statPill('전체 선생님', teachers.length)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _statPill('지금 수업 중', inClassCount)),
|
||||
],
|
||||
),
|
||||
if (_controller.ranking.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildRanking(),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_sectionHeader('방문 목적'),
|
||||
const SizedBox(height: 10),
|
||||
_buildPurposeChips(),
|
||||
if (_useCustomPurpose) ...[
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _customPurposeController,
|
||||
maxLength: 40,
|
||||
decoration: InputDecoration(
|
||||
hintText: '용무를 입력하세요',
|
||||
filled: true,
|
||||
fillColor: AppPalette.paper,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: AppPalette.sage),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_sectionHeader('선생님 목록', count: teachers.length),
|
||||
const SizedBox(height: 10),
|
||||
_buildTeacherGrid(),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _selectedTeacherId == null
|
||||
? Colors.grey
|
||||
: AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
),
|
||||
onPressed: _controller.isCalling ? null : _call,
|
||||
child: _controller.isCalling
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_selectedTeacherName == null
|
||||
? '선생님을 선택해 주세요'
|
||||
: '$_selectedTeacherName 선생님 호출하기',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_sectionHeader(
|
||||
'내 호출 기록',
|
||||
count: _controller.myCalls.length,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_buildMyCalls(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 🪶 "실시간 출석 현황"의 _sidebarStatPill과 같은 필 모양 통계 표시(선택 불가, 숫자 강조용).
|
||||
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 _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]),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static const List<Color> _medalColors = [
|
||||
Color(0xFFC9A227), // 금
|
||||
Color(0xFF9AA0A6), // 은
|
||||
Color(0xFFB07A4B), // 동
|
||||
];
|
||||
|
||||
Widget _buildRanking() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppPalette.sage),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.03),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'오늘 인기 선생님',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
for (var i = 0; i < _controller.ranking.length; i++)
|
||||
Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.emoji_events_rounded,
|
||||
color: _medalColors[i],
|
||||
size: 22,
|
||||
),
|
||||
Text(
|
||||
_controller.ranking[i]['teacherName'] ?? '',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'${_controller.ranking[i]['count']}회',
|
||||
style: TextStyle(color: Colors.grey[500], fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 🍬 필 모양 선택 버튼(스탯 필과 같은 톤: 선택=ink 배경, 미선택=paper+sage 테두리).
|
||||
Widget _pillChoice(String label, bool selected, VoidCallback onTap) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppPalette.ink : AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: selected ? AppPalette.ink : AppPalette.sage,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: selected ? Colors.white : AppPalette.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPurposeChips() {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final p in kCallPurposes)
|
||||
_pillChoice(
|
||||
p,
|
||||
!_useCustomPurpose && _selectedPurpose == p,
|
||||
() => setState(() {
|
||||
_useCustomPurpose = false;
|
||||
_selectedPurpose = p;
|
||||
}),
|
||||
),
|
||||
_pillChoice(
|
||||
'직접 입력',
|
||||
_useCustomPurpose,
|
||||
() => setState(() => _useCustomPurpose = true),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTeacherGrid() {
|
||||
final teachers = _controller.teachers;
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 160,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisExtent: 110,
|
||||
),
|
||||
itemCount: teachers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final t = teachers[index];
|
||||
final name = t['name'] ?? '';
|
||||
final location = t['location'] ?? '교무실';
|
||||
final isSelected = _selectedTeacherId == t['teacherId'];
|
||||
final inClass = _controller.isInClassNow(name);
|
||||
final statusColor = inClass ? AppPalette.warning : AppPalette.ink;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_selectedTeacherId = t['teacherId'];
|
||||
_selectedTeacherName = name;
|
||||
}),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppPalette.ink : AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppPalette.ink : AppPalette.sage,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.03),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Colors.white.withValues(alpha: 0.15)
|
||||
: statusColor.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
inClass ? Icons.school_rounded : Icons.person_rounded,
|
||||
color: isSelected ? Colors.white : statusColor,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (inClass)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Colors.white.withValues(alpha: 0.15)
|
||||
: AppPalette.warningBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Colors.white54
|
||||
: AppPalette.warning,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'수업중',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: AppPalette.warning,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
color: isSelected ? Colors.white : AppPalette.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
kLocationIcons[location] ?? Icons.location_on_rounded,
|
||||
size: 12,
|
||||
color: isSelected ? Colors.white70 : Colors.grey[500],
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Expanded(
|
||||
child: Text(
|
||||
location,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isSelected ? Colors.white70 : Colors.grey[500],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMyCalls() {
|
||||
if (_controller.myCalls.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('아직 호출 기록이 없어요.', style: TextStyle(color: Colors.grey)),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final c in _controller.myCalls)
|
||||
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('${c['teacherName']} 선생님 · ${c['purpose']}'),
|
||||
),
|
||||
Text(
|
||||
'${c['createdAt'] ?? ''}'.split(' ').last,
|
||||
style: TextStyle(color: Colors.grey[500], fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// 📍 선생님 위치 등록 화면(교사용) (UI 전용). "실시간 출석 현황" 화면과 같은 패밀리룩
|
||||
// (제목 알약 + 필 모양 통계 + 그림자 있는 둥근 타일)을 따른다.
|
||||
// 서버 통신/상태는 lib/function/teacher_location_controller.dart가 담당한다.
|
||||
import 'package:flutter/material.dart';
|
||||
import '../data/teacher_call_schedule.dart';
|
||||
import '../function/teacher_location_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class TeacherLocationScreen extends StatefulWidget {
|
||||
final String teacherId;
|
||||
|
||||
const TeacherLocationScreen({super.key, required this.teacherId});
|
||||
|
||||
@override
|
||||
State<TeacherLocationScreen> createState() => _TeacherLocationScreenState();
|
||||
}
|
||||
|
||||
class _TeacherLocationScreenState extends State<TeacherLocationScreen> {
|
||||
late final TeacherLocationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TeacherLocationController(teacherId: widget.teacherId);
|
||||
_controller.init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _selectLocation(String location) async {
|
||||
final (success, message) = await _controller.updateLocation(location);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(
|
||||
context,
|
||||
message,
|
||||
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||
);
|
||||
}
|
||||
|
||||
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]),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 넓은 화면(웹 데스크톱)에서 내용이 양옆으로 늘어지지 않게 가운데 최대 560 폭으로 맞춘다.
|
||||
double _sidePad(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
return ((width - 560) / 2).clamp(16.0, double.infinity);
|
||||
}
|
||||
|
||||
@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,
|
||||
),
|
||||
body: _controller.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _controller.fetchReceivedCalls,
|
||||
child: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
_sidePad(context),
|
||||
0,
|
||||
_sidePad(context),
|
||||
16,
|
||||
),
|
||||
children: [
|
||||
const Center(child: TitlePill('내 위치 알리기')),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.ink,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'현재 위치',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
kLocationIcons[_controller.currentLocation] ??
|
||||
Icons.location_on_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_controller.currentLocation,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 20,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_sectionHeader('위치 선택'),
|
||||
const SizedBox(height: 10),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 160,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisExtent: 90,
|
||||
),
|
||||
itemCount: kLocationOptions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final loc = kLocationOptions[index];
|
||||
final isSelected = _controller.currentLocation == loc;
|
||||
return GestureDetector(
|
||||
onTap: _controller.isSaving
|
||||
? null
|
||||
: () => _selectLocation(loc),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppPalette.ink
|
||||
: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? AppPalette.ink
|
||||
: AppPalette.sage,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.03),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Colors.white.withValues(
|
||||
alpha: 0.15,
|
||||
)
|
||||
: AppPalette.linen,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
kLocationIcons[loc] ??
|
||||
Icons.location_on_rounded,
|
||||
size: 16,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
loc,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_sectionHeader(
|
||||
'나를 찾은 학생 기록',
|
||||
count: _controller.receivedCalls.length,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_buildReceivedCalls(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReceivedCalls() {
|
||||
if (_controller.receivedCalls.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('아직 호출 기록이 없어요.', style: TextStyle(color: Colors.grey)),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final c in _controller.receivedCalls)
|
||||
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.notifications_active_rounded,
|
||||
size: 16,
|
||||
color: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text('${c['studentName']} 학생 · ${c['purpose']}'),
|
||||
),
|
||||
Text(
|
||||
'${c['createdAt'] ?? ''}',
|
||||
style: TextStyle(color: Colors.grey[500], fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/teacher_register_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class TeacherRegisterScreen extends StatefulWidget {
|
||||
const TeacherRegisterScreen({super.key});
|
||||
@@ -35,9 +37,7 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
|
||||
String secret = _secretController.text.trim();
|
||||
|
||||
if (id.isEmpty || pw.isEmpty || name.isEmpty || secret.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('모든 빈칸을 입력해 주세요.')));
|
||||
AppNotice.show(context, '모든 빈칸을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,9 +48,7 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
|
||||
secretCode: secret,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
AppNotice.show(context, message);
|
||||
if (success) {
|
||||
Navigator.pop(context); // 가입 성공 시 로그인 화면으로 복귀
|
||||
}
|
||||
@@ -64,9 +62,11 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
appBar: AppBar(
|
||||
title: const Text('교사 회원가입'),
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const TitlePill('교사 회원가입'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:flutter/material.dart';
|
||||
import '../function/excel_file_picker.dart';
|
||||
import '../function/teacher_student_management_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
// 📐 "실시간 출석 확인" 목록(teacher_attendance_page.dart)의 타일 규격과 동일하게 맞춘다.
|
||||
const double kAttendanceTileWidth = 220;
|
||||
@@ -51,9 +53,7 @@ class _TeacherStudentManagementPageState
|
||||
final sName = _addNameController.text.trim();
|
||||
|
||||
if (sId.isEmpty || sName.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('모든 입력란을 채워주세요.')));
|
||||
AppNotice.show(context, '모든 입력란을 채워주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,9 +69,7 @@ class _TeacherStudentManagementPageState
|
||||
_addNameController.clear();
|
||||
setState(() => _selectedGrade = null);
|
||||
}
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
|
||||
// 📄 [파일 선택 버튼 동작] "파일 선택"으로 엑셀 고르기
|
||||
@@ -89,16 +87,12 @@ class _TeacherStudentManagementPageState
|
||||
rows = _controller.parseExcelBytes(bytes);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('엑셀 파일을 읽는 데 실패했습니다: $e')));
|
||||
AppNotice.show(context, '엑셀 파일을 읽는 데 실패했습니다: $e');
|
||||
return;
|
||||
}
|
||||
if (rows.isEmpty) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)')),
|
||||
);
|
||||
AppNotice.show(context, '유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)');
|
||||
return;
|
||||
}
|
||||
_showBulkPreviewDialog(rows);
|
||||
@@ -156,6 +150,7 @@ class _TeacherStudentManagementPageState
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
autofocus: true,
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_runBulkImport(rows);
|
||||
@@ -229,7 +224,10 @@ class _TeacherStudentManagementPageState
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
result.failures.join('\n'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.red),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppPalette.danger,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -239,6 +237,7 @@ class _TeacherStudentManagementPageState
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('확인'),
|
||||
),
|
||||
@@ -280,9 +279,7 @@ class _TeacherStudentManagementPageState
|
||||
grade: selected,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
|
||||
// 🔓 [목록 행의 "기기 리셋" 버튼] 학생이 폰을 바꿨을 때 등록 초기화.
|
||||
@@ -301,6 +298,7 @@ class _TeacherStudentManagementPageState
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
autofocus: true,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
@@ -312,9 +310,7 @@ class _TeacherStudentManagementPageState
|
||||
studentName,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
AppNotice.show(context, message);
|
||||
},
|
||||
child: const Text('초기화 승인'),
|
||||
),
|
||||
@@ -331,7 +327,7 @@ class _TeacherStudentManagementPageState
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.red[700]),
|
||||
Icon(Icons.warning_amber_rounded, color: AppPalette.danger),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'계정 완전 삭제',
|
||||
@@ -348,8 +344,9 @@ class _TeacherStudentManagementPageState
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
autofocus: true,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red[600],
|
||||
backgroundColor: AppPalette.danger,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () async {
|
||||
@@ -358,9 +355,7 @@ class _TeacherStudentManagementPageState
|
||||
studentId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
AppNotice.show(context, message);
|
||||
},
|
||||
child: const Text('영구 삭제'),
|
||||
),
|
||||
@@ -370,6 +365,20 @@ class _TeacherStudentManagementPageState
|
||||
}
|
||||
|
||||
// 🏷️ 섹션 제목 (색 인디케이터 바 + 텍스트).
|
||||
Widget _gradeFilterChip(int? grade, String label) {
|
||||
final bool selected = _controller.gradeFilter == grade;
|
||||
return ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: selected,
|
||||
onSelected: (_) => _controller.setGradeFilter(grade),
|
||||
selectedColor: AppPalette.ink,
|
||||
labelStyle: TextStyle(
|
||||
color: selected ? Colors.white : AppPalette.ink,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(String text, {Color color = AppPalette.ink}) {
|
||||
return Row(
|
||||
children: [
|
||||
@@ -517,10 +526,10 @@ class _TeacherStudentManagementPageState
|
||||
height: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: _isDragging ? Colors.orange[50] : AppPalette.paper,
|
||||
color: _isDragging ? AppPalette.warningBg : AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: _isDragging ? Colors.orange : AppPalette.sage,
|
||||
color: _isDragging ? AppPalette.warning : AppPalette.sage,
|
||||
width: _isDragging ? 2 : 1,
|
||||
),
|
||||
),
|
||||
@@ -530,7 +539,7 @@ class _TeacherStudentManagementPageState
|
||||
Icon(
|
||||
Icons.upload_file_rounded,
|
||||
size: 40,
|
||||
color: _isDragging ? Colors.orange : AppPalette.sage,
|
||||
color: _isDragging ? AppPalette.warning : AppPalette.sage,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
@@ -538,7 +547,7 @@ class _TeacherStudentManagementPageState
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _isDragging ? Colors.orange[800] : AppPalette.ink,
|
||||
color: _isDragging ? AppPalette.warning : AppPalette.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -582,15 +591,13 @@ class _TeacherStudentManagementPageState
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: needsReset
|
||||
? Colors.red.withValues(alpha: 0.1)
|
||||
: AppPalette.linen,
|
||||
color: needsReset ? AppPalette.dangerBg : AppPalette.linen,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
needsReset ? Icons.lock_reset : Icons.person,
|
||||
size: 18,
|
||||
color: needsReset ? Colors.red : AppPalette.ink,
|
||||
color: needsReset ? AppPalette.danger : AppPalette.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -607,7 +614,7 @@ class _TeacherStudentManagementPageState
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: needsReset ? Colors.red[700] : Colors.grey[600],
|
||||
color: needsReset ? AppPalette.danger : Colors.grey[600],
|
||||
fontWeight: needsReset ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
@@ -648,7 +655,7 @@ class _TeacherStudentManagementPageState
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.delete_forever_rounded,
|
||||
color: Colors.redAccent,
|
||||
color: AppPalette.danger,
|
||||
size: 18,
|
||||
),
|
||||
tooltip: '계정 영구 삭제',
|
||||
@@ -671,13 +678,11 @@ class _TeacherStudentManagementPageState
|
||||
return Scaffold(
|
||||
backgroundColor: AppPalette.mist,
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'학생 통합 관리 센터',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppPalette.ink,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const TitlePill('학생 통합 관리 센터'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
@@ -767,6 +772,42 @@ class _TeacherStudentManagementPageState
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 6,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: isWide ? formSquareSize : 160,
|
||||
child: TextField(
|
||||
onChanged: _controller.setSearchQuery,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
hintText: '학번으로 검색',
|
||||
prefixIcon: const Icon(
|
||||
Icons.search_rounded,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppPalette.paper,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 0,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: AppPalette.sage,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_gradeFilterChip(null, '전체'),
|
||||
_gradeFilterChip(1, '1학년'),
|
||||
_gradeFilterChip(2, '2학년'),
|
||||
_gradeFilterChip(3, '3학년'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_controller.isLoadingStudents
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 40),
|
||||
@@ -774,13 +815,19 @@ class _TeacherStudentManagementPageState
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: _controller.students.isEmpty
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
: _controller.filteredStudents.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 24,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'가입된 학생 계정이 없습니다.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
_controller.students.isEmpty
|
||||
? '가입된 학생 계정이 없습니다.'
|
||||
: '검색/필터 조건에 맞는 학생이 없습니다.',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -795,10 +842,11 @@ class _TeacherStudentManagementPageState
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: _controller.students.length,
|
||||
itemCount:
|
||||
_controller.filteredStudents.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildStudentTile(
|
||||
_controller.students[index],
|
||||
_controller.filteredStudents[index],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// 🪶 검정 AppBar 대신 쓰는 둥근 제목 알약. 모든 화면이 똑같은 폰트/크기를 쓰고,
|
||||
// 화면 맨 위에 딱 붙어 보이지 않도록 위쪽에 살짝 여백을 둔다.
|
||||
// 🛍️ 프로필 꾸미기 템플릿을 장착하면 이 알약 배경도 같이 바뀐다 - 앱 전체가
|
||||
// 공유하는 위젯이라 여기 하나만 고치면 모든 화면의 제목 배경이 한번에 바뀐다.
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../theme/theme_sync.dart';
|
||||
|
||||
class TitlePill extends StatelessWidget {
|
||||
final String text;
|
||||
|
||||
const TitlePill(this.text, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: globalProfileTheme,
|
||||
builder: (context, theme, _) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme?.secondary ?? AppPalette.ink,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
archive:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: archive
|
||||
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
|
||||
|
||||
@@ -54,6 +54,7 @@ dependencies:
|
||||
# 📳 무단 반출 감지 시 진동 (백그라운드 서비스에서도 동작하도록 HapticFeedback 대신 사용)
|
||||
vibration: ^3.2.0
|
||||
excel: ^4.0.6
|
||||
archive: ^3.6.1
|
||||
file_picker: ^12.1.2
|
||||
desktop_drop: ^0.8.3
|
||||
web: ^1.1.1
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# 교무실 Stream Deck 선생님 위치 표시기
|
||||
|
||||
교무실 출입구에 놓인 Stream Deck으로 선생님이 본인 이름 버튼만 누르면
|
||||
백품타 앱의 "선생님 호출" 위치가 바뀝니다. 이미 배포돼 있는 서버 API
|
||||
(`POST /api/teacher-call/location`)를 그대로 호출하기 때문에, 서버나
|
||||
앱은 전혀 건드릴 필요 없이 이 스크립트 하나만 라즈베리파이 같은 작은
|
||||
컴퓨터에서 계속 띄워두면 됩니다.
|
||||
|
||||
## 준비물
|
||||
|
||||
- Stream Deck 오리지널/MK.2 (15키, 5x3) — USB로 라즈베리파이에 연결
|
||||
- 라즈베리파이(또는 인터넷 되는 리눅스 미니PC) 1대
|
||||
- 교무실 회원가입(교사 회원가입)을 마친 선생님만 목록에 뜹니다 — 아직
|
||||
가입 안 한 선생님은 버튼이 안 보이니, 먼저 앱에서 "교사 회원가입"부터
|
||||
해달라고 안내해주세요.
|
||||
|
||||
## 설치
|
||||
|
||||
```bash
|
||||
# 1) 한글 폰트 설치 (버튼에 이름이 네모(□)로 안 뜨게)
|
||||
sudo apt update
|
||||
sudo apt install -y fonts-nanum
|
||||
|
||||
# 2) Stream Deck을 일반 사용자 권한으로 인식하게 하는 udev 규칙
|
||||
# (이거 안 하면 sudo로만 인식되거나 아예 인식이 안 될 수 있어요)
|
||||
sudo tee /etc/udev/rules.d/50-streamdeck.rules > /dev/null << 'UDEV'
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", MODE="0666"
|
||||
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0fd9", MODE="0666"
|
||||
UDEV
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger
|
||||
|
||||
# 3) 파이썬 패키지 설치
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 4) 실행
|
||||
python3 stream_deck_teacher_status.py
|
||||
```
|
||||
|
||||
USB 케이블을 뽑았다 다시 꽂아야 udev 규칙이 적용되는 경우가 있어요.
|
||||
|
||||
## 사용법
|
||||
|
||||
- **선생님 이름 버튼을 그냥 누르면** → "수업 중"으로 표시됩니다.
|
||||
- **과학실/도서관/출장 등 다른 위치로 표시하고 싶으면** → 화면 오른쪽 아래
|
||||
"⚙ 설정" 버튼을 누르고, 뜨는 위치 목록에서 하나를 고른 뒤(예: "출장"),
|
||||
이어서 그 선생님 이름 버튼을 누르면 됩니다. 한 번 적용하면 자동으로
|
||||
다시 기본 모드("수업 중")로 돌아갑니다.
|
||||
- 선생님이 18명을 넘어가면 자동으로 페이지가 나뉘고, "다음 ▶" 버튼으로
|
||||
넘길 수 있습니다.
|
||||
- 새로 교사 회원가입을 한 선생님은 최대 1분 안에 자동으로 목록에 나타납니다
|
||||
(재시작 필요 없음).
|
||||
|
||||
## 부팅할 때 자동 실행하게 하기 (선택)
|
||||
|
||||
라즈베리파이를 켤 때마다 자동으로 이 스크립트가 실행되게 하려면
|
||||
systemd 서비스로 등록하세요:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/systemd/system/streamdeck-teacher.service > /dev/null << 'UNIT'
|
||||
[Unit]
|
||||
Description=교무실 Stream Deck 선생님 위치 표시기
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/home/pi/stream_deck_teacher_status/venv/bin/python3 /home/pi/stream_deck_teacher_status/stream_deck_teacher_status.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
User=pi
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now streamdeck-teacher.service
|
||||
```
|
||||
|
||||
경로(`/home/pi/...`)는 실제로 이 폴더를 둔 위치에 맞게 바꿔주세요.
|
||||
로그 확인은 `journalctl -u streamdeck-teacher -f`.
|
||||
|
||||
## 참고
|
||||
|
||||
- 이 스크립트는 이 저장소(school-backend)와는 별개로, 이미 배포된
|
||||
`api.backsanhi.shop`의 기존 API만 호출합니다. 서버/앱 배포와는 무관하게
|
||||
라즈베리파이에서 이 파일만 갖고 있으면 바로 동작합니다.
|
||||
- `python-elgato-streamdeck` 라이브러리 버전에 따라 내부 헬퍼 함수 이름이
|
||||
달라서(create_key_image/create_image 등) 코드에 호환 처리를 넣어뒀지만,
|
||||
혹시 실행 중 `AttributeError`가 나면 캡처해서 알려주세요.
|
||||
@@ -0,0 +1,3 @@
|
||||
streamdeck
|
||||
pillow
|
||||
requests
|
||||
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
🎛️ 교무실 출입구용 Stream Deck 선생님 위치 표시기.
|
||||
|
||||
선생님이 교무실을 나가실 때 본인 이름 버튼을 한 번 누르면 "수업 중"으로,
|
||||
"설정" 버튼을 눌러 과학실/도서관/출장 같은 위치를 먼저 고른 뒤 이름 버튼을
|
||||
누르면 그 위치로 우리 학교 앱(백품타 선생님 호출)에 표시된다.
|
||||
|
||||
이미 만들어져 있는 백엔드 API(POST /api/teacher-call/location)를 그대로
|
||||
호출하기 때문에 서버/앱 쪽은 아무것도 건드릴 필요가 없다. 이 스크립트는
|
||||
라즈베리파이 같은 작은 컴퓨터에 Stream Deck을 USB로 꽂아두고 계속 띄워두면 된다.
|
||||
|
||||
설치/실행 방법은 같은 폴더의 README.md 참고.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from StreamDeck.DeviceManager import DeviceManager
|
||||
from StreamDeck.ImageHelpers import PILHelper
|
||||
|
||||
# 🩹 [버전 호환] python-elgato-streamdeck 버전에 따라 이 헬퍼 함수 이름이
|
||||
# create_key_image/create_image, to_native_key_format/to_native_format로
|
||||
# 다르게 붙어있어서, 설치된 버전에 맞는 이름을 찾아 쓴다.
|
||||
_create_key_image = getattr(PILHelper, "create_key_image", None) or PILHelper.create_image
|
||||
_to_native_key_format = getattr(PILHelper, "to_native_key_format", None) or PILHelper.to_native_format
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# ⚙️ 설정
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
API_BASE = "https://api.backsanhi.shop"
|
||||
TEACHERS_URL = f"{API_BASE}/api/teacher-call/teachers"
|
||||
LOCATION_URL_TMPL = f"{API_BASE}/api/teacher-call/location"
|
||||
|
||||
# 이름 버튼을 그냥 한 번 눌렀을 때(설정모드 아닐 때) 적용되는 기본 위치.
|
||||
DEFAULT_TAP_LOCATION = "수업 중"
|
||||
|
||||
# "설정" 모드에서 고를 수 있는 위치 목록 (앱의 kLocationOptions와 맞춰둠).
|
||||
LOCATION_OPTIONS = [
|
||||
"교무실", "급식실", "과학실", "도서관",
|
||||
"출장", "조퇴/퇴근", "상담실", "기숙사",
|
||||
]
|
||||
|
||||
ROSTER_REFRESH_SECONDS = 60 # 선생님 목록(누가 회원가입했는지)을 다시 불러오는 주기.
|
||||
FONT_PATH_CANDIDATES = [
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
|
||||
]
|
||||
|
||||
TEACHERS_PER_PAGE = 13 # 페이지당 선생님 버튼 개수 (나머지 2칸은 다음/설정 버튼용).
|
||||
|
||||
|
||||
def _find_font(size: int) -> ImageFont.FreeTypeFont:
|
||||
for path in FONT_PATH_CANDIDATES:
|
||||
try:
|
||||
return ImageFont.truetype(path, size)
|
||||
except OSError:
|
||||
continue
|
||||
print(
|
||||
"⚠️ 한글 폰트를 못 찾았어요. 'sudo apt install fonts-nanum' 등으로 설치해주세요. "
|
||||
"일단 기본 폰트로 진행합니다 (한글이 네모로 보일 수 있어요)."
|
||||
)
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
class TeacherStatusDeck:
|
||||
def __init__(self, deck):
|
||||
self.deck = deck
|
||||
self.key_font = _find_font(14)
|
||||
self.small_font = _find_font(11)
|
||||
|
||||
self.roster: list[dict] = [] # [{studentId, name, location}]
|
||||
self.page = 0
|
||||
self.settings_mode = False
|
||||
self.pending_location: Optional[str] = None
|
||||
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ── 선생님 목록 새로고침 ────────────────────────────────────────────
|
||||
def refresh_roster(self):
|
||||
try:
|
||||
resp = requests.get(TEACHERS_URL, timeout=5)
|
||||
resp.raise_for_status()
|
||||
teachers = resp.json().get("teachers", [])
|
||||
with self._lock:
|
||||
self.roster = sorted(teachers, key=lambda t: t["name"])
|
||||
except Exception as e:
|
||||
print(f"🚨 선생님 목록 불러오기 실패: {e}")
|
||||
|
||||
def roster_watcher(self):
|
||||
while True:
|
||||
self.refresh_roster()
|
||||
self.redraw()
|
||||
time.sleep(ROSTER_REFRESH_SECONDS)
|
||||
|
||||
# ── 이미지 그리기 ───────────────────────────────────────────────────
|
||||
def _blank_key_image(self, color=(30, 30, 30)):
|
||||
image = _create_key_image(self.deck)
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((0, 0, image.width, image.height), fill=color)
|
||||
return image, draw
|
||||
|
||||
def _text_key_image(self, lines, bg=(30, 30, 30), fg=(255, 255, 255)):
|
||||
image, draw = self._blank_key_image(bg)
|
||||
total_h = len(lines) * 16
|
||||
y = (image.height - total_h) // 2
|
||||
for i, (text, font) in enumerate(lines):
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
w = bbox[2] - bbox[0]
|
||||
draw.text(
|
||||
((image.width - w) // 2, y + i * 16),
|
||||
text, font=font, fill=fg,
|
||||
)
|
||||
return _to_native_key_format(self.deck, image)
|
||||
|
||||
def _nav_key_image(self, label, bg=(60, 60, 70)):
|
||||
return self._text_key_image(
|
||||
[(part, self.key_font) for part in label.split("\n")], bg=bg
|
||||
)
|
||||
|
||||
def _blank_native_image(self):
|
||||
image, _ = self._blank_key_image()
|
||||
return _to_native_key_format(self.deck, image)
|
||||
|
||||
# ── 화면(버튼 배치) 그리기 ──────────────────────────────────────────
|
||||
def redraw(self):
|
||||
with self._lock:
|
||||
for key in range(self.deck.key_count()):
|
||||
self.deck.set_key_image(key, self._render_key(key))
|
||||
|
||||
def _render_key(self, key: int):
|
||||
if self.settings_mode:
|
||||
return self._render_settings_key(key)
|
||||
return self._render_teacher_key(key)
|
||||
|
||||
def _render_settings_key(self, key: int):
|
||||
if key < len(LOCATION_OPTIONS):
|
||||
loc = LOCATION_OPTIONS[key]
|
||||
selected = self.pending_location == loc
|
||||
bg = (0, 110, 60) if selected else (45, 45, 45)
|
||||
return self._text_key_image([(loc, self.key_font)], bg=bg)
|
||||
if key == self.deck.key_count() - 1:
|
||||
return self._nav_key_image("취소", bg=(120, 40, 40))
|
||||
return self._blank_native_image()
|
||||
|
||||
def _render_teacher_key(self, key: int):
|
||||
last_key = self.deck.key_count() - 1
|
||||
nav_key = self.deck.key_count() - 2
|
||||
|
||||
start = self.page * TEACHERS_PER_PAGE
|
||||
page_roster = self.roster[start:start + TEACHERS_PER_PAGE]
|
||||
|
||||
if key < len(page_roster):
|
||||
teacher = page_roster[key]
|
||||
bg = (0, 90, 140) if self.pending_location else (35, 35, 35)
|
||||
return self._text_key_image(
|
||||
[
|
||||
(teacher["name"], self.key_font),
|
||||
(teacher.get("location", ""), self.small_font),
|
||||
],
|
||||
bg=bg,
|
||||
)
|
||||
if key == nav_key:
|
||||
has_more_pages = len(self.roster) > TEACHERS_PER_PAGE
|
||||
if not has_more_pages:
|
||||
return self._blank_native_image()
|
||||
max_page = (len(self.roster) - 1) // TEACHERS_PER_PAGE
|
||||
return self._nav_key_image(f"다음 ▶\n({self.page + 1}/{max_page + 1})")
|
||||
if key == last_key:
|
||||
label = f"설정\n({self.pending_location})" if self.pending_location else "⚙ 설정"
|
||||
bg = (150, 90, 0) if self.pending_location else (60, 60, 70)
|
||||
return self._text_key_image(
|
||||
[(part, self.key_font) for part in label.split("\n")], bg=bg
|
||||
)
|
||||
return self._blank_native_image()
|
||||
|
||||
# ── 버튼 입력 처리 ──────────────────────────────────────────────────
|
||||
def on_key_change(self, deck, key, pressed):
|
||||
if not pressed:
|
||||
return
|
||||
if self.settings_mode:
|
||||
self._handle_settings_press(key)
|
||||
else:
|
||||
self._handle_teacher_press(key)
|
||||
self.redraw()
|
||||
|
||||
def _handle_settings_press(self, key: int):
|
||||
if key < len(LOCATION_OPTIONS):
|
||||
self.pending_location = LOCATION_OPTIONS[key]
|
||||
self.settings_mode = False
|
||||
return
|
||||
# 마지막 키 = 취소
|
||||
self.settings_mode = False
|
||||
|
||||
def _handle_teacher_press(self, key: int):
|
||||
last_key = self.deck.key_count() - 1
|
||||
nav_key = self.deck.key_count() - 2
|
||||
|
||||
start = self.page * TEACHERS_PER_PAGE
|
||||
page_roster = self.roster[start:start + TEACHERS_PER_PAGE]
|
||||
|
||||
if key < len(page_roster):
|
||||
teacher = page_roster[key]
|
||||
location = self.pending_location or DEFAULT_TAP_LOCATION
|
||||
self._set_location(teacher, location)
|
||||
self.pending_location = None # 한 번 적용하면 자동으로 기본 모드로.
|
||||
return
|
||||
|
||||
if key == nav_key and len(self.roster) > TEACHERS_PER_PAGE:
|
||||
max_page = (len(self.roster) - 1) // TEACHERS_PER_PAGE
|
||||
self.page = (self.page + 1) % (max_page + 1)
|
||||
return
|
||||
|
||||
if key == last_key:
|
||||
if self.pending_location:
|
||||
# 설정 모드에서 위치를 이미 골라둔 상태로 설정 버튼을 또 누르면 취소.
|
||||
self.pending_location = None
|
||||
else:
|
||||
self.settings_mode = True
|
||||
|
||||
def _set_location(self, teacher: dict, location: str):
|
||||
try:
|
||||
resp = requests.post(
|
||||
LOCATION_URL_TMPL,
|
||||
json={"teacherId": teacher["studentId"], "location": location},
|
||||
timeout=5,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
print(f"✅ {teacher['name']} → {location}")
|
||||
teacher["location"] = location # 다음 새로고침 전까지 화면에 바로 반영.
|
||||
except Exception as e:
|
||||
print(f"🚨 위치 업데이트 실패 ({teacher['name']} → {location}): {e}")
|
||||
|
||||
|
||||
def main():
|
||||
decks = DeviceManager().enumerate()
|
||||
if not decks:
|
||||
print("🚨 연결된 Stream Deck을 찾을 수 없어요. USB 연결과 udev 규칙을 확인해주세요 (README 참고).")
|
||||
return
|
||||
|
||||
deck = decks[0]
|
||||
deck.open()
|
||||
deck.reset()
|
||||
deck.set_brightness(70)
|
||||
print(f"🎛️ Stream Deck 연결됨: {deck.deck_type()} ({deck.key_count()}키)")
|
||||
|
||||
controller = TeacherStatusDeck(deck)
|
||||
deck.set_key_callback(controller.on_key_change)
|
||||
|
||||
controller.refresh_roster()
|
||||
controller.redraw()
|
||||
|
||||
threading.Thread(target=controller.roster_watcher, daemon=True).start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
deck.reset()
|
||||
deck.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -19,6 +19,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
<!-- 🎨 Safari 등 브라우저가 탭/툴바를 이 색으로 물들이는데, 예전 Flutter 기본 파란색
|
||||
(#0175C2)이 그대로 남아있어서 스크롤 시 파랗게 보였다. 앱 색상으로 교체. -->
|
||||
<meta name="theme-color" content="#1C1C1C">
|
||||
<meta name="msapplication-navbutton-color" content="#1C1C1C">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -31,6 +35,22 @@
|
||||
|
||||
<title>school_attendance</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
<!-- 🌊 화면 크기에 딱 맞춰서 페이지 자체가 스크롤/바운스되지 않게 고정한다.
|
||||
position: fixed로 문서를 화면에 못박아 버려서, 트랙패드로 스크롤 경계를
|
||||
넘어가도 사파리/크롬이 바운스할 대상 자체가 없다 (안쪽 콘텐츠 스크롤은
|
||||
Flutter가 알아서 처리하므로 영향 없음). -->
|
||||
<style>
|
||||
html, body {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
background-color: #EEF0F2;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@
|
||||
"short_name": "school_attendance",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"background_color": "#EEF0F2",
|
||||
"theme_color": "#1C1C1C",
|
||||
"description": "A new Flutter project.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
|
||||
Reference in New Issue
Block a user