2 Commits
Author SHA1 Message Date
sihooandClaude Sonnet 5 89585ec23f release: 1.1.5 Android 빌드 호환성 수정
AGP 9 환경에서 desktop_drop 플러그인의 Kotlin Gradle Plugin
적용 방식이 깨져 있어 릴리즈 APK 빌드가 실패하던 문제를 해결.
desktop_drop을 0.8.3으로 올리고, :desktop_drop 서브프로젝트에
kotlin-android 플러그인을 명시적으로 적용하도록 root build.gradle.kts에
예외 처리 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 19:31:54 +09:00
sihooandClaude Sonnet 5 b304c7ebce release: 1.1.5 (패드 반출 시스템 이전 안정 버전)
스마트기기 반출 기능 병합 전 상태를 1.1.5 릴리즈로 고정.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 19:24:32 +09:00
74 changed files with 1682 additions and 10604 deletions
-134
View File
@@ -1,134 +0,0 @@
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 = [
"간단한 용무",
"질문 있어요",
"상담 신청",
"과제 제출",
"물건 전달",
];
+4 -1
View File
@@ -13,7 +13,10 @@ class DebugPocketApp extends StatelessWidget {
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: NfcPocketCheckInScreen(studentId: "2061", studentName: "디버그테스트"),
home: NfcPocketCheckInScreen(
studentId: "2061",
studentName: "디버그테스트",
),
);
}
}
+3 -3
View File
@@ -17,12 +17,12 @@ class AdminController extends ChangeNotifier {
try {
final response = await http.get(url);
if (response.statusCode == 200) {
return 'DB가 깔끔하게 초기화되었습니다! (출석 번호 1번부터 시작)';
return '💥 DB가 깔끔하게 초기화되었습니다! (출석 번호 1번부터 시작)';
} else {
return '초기화 실패: 서버 권한 오류 (${response.statusCode})';
return '❌ 초기화 실패: 서버 권한 오류 (${response.statusCode})';
}
} catch (e) {
return '네트워크 에러: 서버와 연결할 수 없습니다.';
return '❌ 네트워크 에러: 서버와 연결할 수 없습니다.';
} finally {
_isLoading = false;
notifyListeners();
-76
View File
@@ -1,76 +0,0 @@
// 🗣️ 커뮤니티 게시판 - 카테고리별 게시글 목록 화면의 기능(서버 통신/상태) 담당 컨트롤러.
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 (_) {
// 조용히 무시 - 마지막으로 받아온 목록을 유지한다.
}
}
}
-143
View File
@@ -1,143 +0,0 @@
// 💬 커뮤니티 게시판 - 게시글 상세(+댓글) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
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');
}
}
}
-77
View File
@@ -1,77 +0,0 @@
// 🚩 커뮤니티 게시판 - 신고 검토(운영자용) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
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();
}
}
}
-48
View File
@@ -1,48 +0,0 @@
// ✍️ 커뮤니티 게시판 - 글쓰기 화면의 기능(서버 통신/상태) 담당 컨트롤러.
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();
}
}
}
-3
View File
@@ -1,3 +0,0 @@
// 🌐 플랫폼에 따라 웹 전용 구현/무동작 구현 중 하나로 연결되는 진입점.
export 'browser_notification_stub.dart'
if (dart.library.html) 'browser_notification_web.dart';
@@ -1,4 +0,0 @@
// 🌐 웹이 아닌 빌드(Android 등)에서는 브라우저 알림 API 자체가 없으니 아무 동작도 안 한다.
Future<bool> requestNotificationPermission() async => false;
void showBrowserNotification(String title, String body) {}
@@ -1,24 +0,0 @@
// 🌐 웹 전용 - 브라우저 알림(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 (_) {
// 조용히 무시
}
}
@@ -1,158 +0,0 @@
// 🧩 대시보드 타일 배치(위치/크기)의 서버 통신/상태 담당 컨트롤러.
// 캔버스는 6열×4행 고정, 타일 크기는 1x1~4x4. 계정별로 서버(계정=userId)에 저장한다.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
import '../models/dashboard_tile_layout.dart';
class DashboardLayoutController extends ChangeNotifier {
final String userId;
DashboardLayoutController(this.userId);
Map<String, TileRect> _positions = {};
bool _isLoading = true;
bool _isSaving = false;
bool _isEditing = false;
Map<String, TileRect> get positions => _positions;
bool get isLoading => _isLoading;
bool get isSaving => _isSaving;
bool get isEditing => _isEditing;
void enterEditMode() {
_isEditing = true;
notifyListeners();
}
/// 편집 중이던 변경을 서버에 저장하지 않고 그대로 유지한 채 편집 모드만 끈다.
/// (저장은 [save]를 명시적으로 불러야 함 — 완료 버튼이 save+exit를 같이 호출한다.)
void exitEditMode() {
_isEditing = false;
notifyListeners();
}
/// 서버에 저장된 배치를 불러오고, 현재 화면에 보여야 할 타일 id 목록([visibleIds]) 중
/// 저장된 값이 없는 새 타일은 빈 칸을 찾아 1x1로 자동 배치한다.
Future<void> load(List<String> visibleIds) async {
_isLoading = true;
notifyListeners();
Map<String, TileRect> loaded = {};
try {
final response = await http.get(
Uri.parse('$baseUrl/api/dashboard/layout?userId=$userId'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
final rawLayout = data['layout'];
if (rawLayout is List) {
for (final item in rawLayout) {
final id = item['id']?.toString();
if (id == null) continue;
final rect = TileRect.fromJson(item);
if (rect.isWithinCanvas) loaded[id] = rect;
}
}
}
} catch (_) {
// 새로고침 실패는 조용히 무시하고 기본 배치로 대체한다.
}
_positions = _fillDefaults(loaded, visibleIds);
_isLoading = false;
notifyListeners();
}
Map<String, TileRect> _fillDefaults(
Map<String, TileRect> existing,
List<String> ids,
) {
final Map<String, TileRect> result = {};
for (final id in ids) {
final saved = existing[id];
if (saved != null && !_collidesWithAny(result, saved, null)) {
result[id] = saved;
}
}
for (final id in ids) {
if (result.containsKey(id)) continue;
final spot = _findFirstFit(result, 1, 1);
if (spot != null) {
result[id] = TileRect(x: spot.$1, y: spot.$2, w: 1, h: 1);
}
// 캔버스(6x4=24칸)가 꽉 찼으면 자리를 못 찾을 수도 있음 — 그 타일은 화면에 안 보이게 됨.
}
return result;
}
bool _collidesWithAny(
Map<String, TileRect> placed,
TileRect candidate,
String? ignoreId,
) {
for (final entry in placed.entries) {
if (entry.key == ignoreId) continue;
if (entry.value.overlaps(candidate)) return true;
}
return false;
}
(int, int)? _findFirstFit(Map<String, TileRect> placed, int w, int h) {
for (int y = 0; y <= kGridRows - h; y++) {
for (int x = 0; x <= kGridCols - w; x++) {
final candidate = TileRect(x: x, y: y, w: w, h: h);
if (!_collidesWithAny(placed, candidate, null)) return (x, y);
}
}
return null;
}
bool _fits(String movingId, TileRect candidate) {
if (!candidate.isWithinCanvas) return false;
return !_collidesWithAny(_positions, candidate, movingId);
}
/// 타일을 (newX, newY)로 옮긴다. 다른 타일과 겹치거나 캔버스를 벗어나면 무시하고 false 반환.
bool tryMove(String id, int newX, int newY) {
final cur = _positions[id];
if (cur == null) return false;
final candidate = cur.copyWith(x: newX, y: newY);
if (!_fits(id, candidate)) return false;
_positions = {..._positions, id: candidate};
notifyListeners();
return true;
}
/// 타일 크기를 (newW, newH)로 바꾼다. 다른 타일과 겹치거나 4x4/캔버스를 벗어나면 무시.
bool tryResize(String id, int newW, int newH) {
final cur = _positions[id];
if (cur == null) return false;
final candidate = cur.copyWith(w: newW, h: newH);
if (!_fits(id, candidate)) return false;
_positions = {..._positions, id: candidate};
notifyListeners();
return true;
}
Future<void> save() async {
_isSaving = true;
notifyListeners();
try {
final layout = _positions.entries
.map((e) => e.value.toJson(e.key))
.toList();
await http.post(
Uri.parse('$baseUrl/api/dashboard/layout'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"userId": userId, "layout": layout}),
);
} catch (_) {
// 저장 실패해도 로컬 배치는 유지 — 다음에 다시 시도 가능.
} finally {
_isSaving = false;
_isEditing = false;
notifyListeners();
}
}
}
@@ -1,48 +0,0 @@
// 📱 스마트기기(패드) 반출 신청 화면의 기능(서버 통신/상태) 담당 컨트롤러.
// NFC가 안 되는 패드용 대체 절차 — 시작~종료 시각과 사용 목적을 적어 신청하면
// 선생님 승인 후 저울(아두이노)이 실제 픽업/반납 여부를 감시한다 (하드웨어는 나중에 붙임).
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class DeviceCheckoutController extends ChangeNotifier {
bool _isSubmitting = false;
bool get isSubmitting => _isSubmitting;
/// ➕ 반출 요청 제출. (성공여부, 메시지)를 반환한다.
Future<(bool success, String message)> submitRequest({
required String studentId,
required String studentName,
required String purpose,
required String startTime,
required String endTime,
}) async {
_isSubmitting = true;
notifyListeners();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/device-checkout/request'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"studentId": studentId,
"studentName": studentName,
"purpose": purpose,
"startTime": startTime,
"endTime": endTime,
}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
return (true, '${result['message'] ?? '요청이 접수되었습니다.'}');
} else {
return (false, '${result['message'] ?? '요청 실패'}');
}
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isSubmitting = false;
notifyListeners();
}
}
}
@@ -1,196 +0,0 @@
// 📋 선생님용 "스마트기기 반출 대장" 화면의 기능(서버 통신/상태) 담당 컨트롤러.
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class DeviceCheckoutLedgerController extends ChangeNotifier {
List<dynamic> _requests = [];
bool _isLoading = true;
bool _isWorking = false;
Timer? _timer;
bool _disposed = false;
List<dynamic> get requests => _requests;
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();
}
void init() {
fetchAll();
_timer = Timer.periodic(const Duration(seconds: 5), (_) => fetchAll());
}
@override
void dispose() {
_disposed = true;
_timer?.cancel();
super.dispose();
}
Future<void> fetchAll() async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/device-checkout/list'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_requests = data['requests'] ?? [];
}
} catch (_) {
// 새로고침 실패는 조용히 무시하고 마지막으로 받아온 목록을 유지한다.
} finally {
_isLoading = false;
_safeNotify();
}
}
// ✅ 승인 시 승인한 선생님 계정을 함께 보낸다 (대장에 "OOO 선생님이 허용했습니다" 표시 +
// 미반납 알림을 그 선생님에게만 보내기 위함).
Future<(bool success, String message)> approve(
int requestId, {
required String teacherId,
required String teacherName,
}) async {
return _postAction('/api/device-checkout/approve', {
"requestId": requestId,
"teacherId": teacherId,
"teacherName": teacherName,
});
}
/// 🙌 [전체 학생 허용] 대상이 될 요청 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});
}
// 🖐️ [하드웨어 자동 감지 전까지 임시] 선생님이 기기를 실제로 돌려받았을 때 누르는 버튼.
Future<(bool success, String message)> confirmReturn(int requestId) async {
return _postAction('/api/device-checkout/return', {"requestId": requestId});
}
Future<(bool success, String message)> _postAction(
String path,
Map<String, dynamic> body,
) async {
_isWorking = true;
_safeNotify();
try {
final response = await http.post(
Uri.parse('$baseUrl$path'),
headers: {"Content-Type": "application/json"},
body: jsonEncode(body),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await fetchAll();
return (true, '${result['message'] ?? '처리되었습니다.'}');
}
return (false, '${result['message'] ?? '처리 실패'}');
} catch (e) {
return (false, '네트워크 에러: $e');
} finally {
_isWorking = false;
_safeNotify();
}
}
}
-152
View File
@@ -1,152 +0,0 @@
// 🧑‍🤝‍🧑 백품타 친구 목록/신청 화면의 기능(서버 통신/상태) 담당 컨트롤러.
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();
}
}
}
-167
View File
@@ -1,167 +0,0 @@
// 👥 백품타 그룹 스터디(목록/생성/초대) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
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();
}
}
}
-175
View File
@@ -1,175 +0,0 @@
// 👥 백품타 그룹 상세(멤버/오늘 출석/초대/나가기) 화면의 기능 담당 컨트롤러.
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();
}
}
}
+13 -18
View File
@@ -16,21 +16,20 @@ 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,
const LoginResult.masterSuccess({
required this.studentId,
required this.name,
}) : outcome = LoginOutcome.masterSuccess,
role = null,
isDeviceMatched = true,
errorMessage = null,
grade = null;
errorMessage = null;
const LoginResult.needsPasswordChange({
required this.studentId,
required this.name,
required this.role,
required this.isDeviceMatched,
this.grade,
}) : outcome = LoginOutcome.needsPasswordChange,
errorMessage = null;
@@ -39,7 +38,6 @@ class LoginResult {
required this.studentId,
required this.name,
required this.isDeviceMatched,
this.grade,
}) : outcome = LoginOutcome.success,
errorMessage = null;
@@ -48,8 +46,7 @@ class LoginResult {
role = null,
studentId = null,
name = null,
isDeviceMatched = false,
grade = null;
isDeviceMatched = false;
}
class LoginController extends ChangeNotifier {
@@ -58,13 +55,16 @@ class LoginController extends ChangeNotifier {
Future<LoginResult> login(String id, String password) async {
if (id.isEmpty || password.isEmpty) {
return const LoginResult.error('학번과 비밀번호를 모두 입력해 주세요.');
return const LoginResult.error('⚠️ 학번과 비밀번호를 모두 입력해 주세요.');
}
// 🔥 [개발자 마스터 계정]
if (id == "2061") {
if (password == "happy9642!") {
return const LoginResult.masterSuccess(studentId: "2061", name: "훗춧가룻");
return const LoginResult.masterSuccess(
studentId: "2061",
name: "훗춧가룻",
);
} else {
return const LoginResult.error('개발자 계정의 마스터 비밀번호가 올바르지 않습니다.');
}
@@ -106,7 +106,6 @@ class LoginController extends ChangeNotifier {
String name = '';
String studentId = '';
int isFirstLogin = 0;
int? grade;
var userObj = resData['user'];
if (userObj != null) {
@@ -120,7 +119,6 @@ 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)
@@ -133,7 +131,6 @@ 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)
@@ -161,7 +158,6 @@ class LoginController extends ChangeNotifier {
name: name,
role: role,
isDeviceMatched: isDeviceMatched,
grade: grade,
);
}
@@ -170,7 +166,6 @@ class LoginController extends ChangeNotifier {
studentId: studentId,
name: name,
isDeviceMatched: isDeviceMatched,
grade: grade,
);
} else {
String errorMsg = '로그인에 실패했습니다.';
@@ -205,9 +200,9 @@ class LoginController extends ChangeNotifier {
final resData = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && resData['status'] == 'success') {
return (true, '비밀번호가 변경되었습니다!');
return (true, '✅ 비밀번호가 변경되었습니다!');
} else {
return (false, '실패: ${resData['message']}');
return (false, '❌ 실패: ${resData['message']}');
}
} catch (e) {
return (false, '서버 에러 발생: $e');
+21 -25
View File
@@ -17,12 +17,8 @@ import '../config.dart' show baseUrl;
// 🛡️ iOS 근접센서 감시용 네이티브 채널 (ios/Runner/ProximityMonitorPlugin.swift 참고).
// 조도 센서 API가 없는 iOS에서, 통화 중 화면이 꺼지는 것과 같은 원리인
// 근접센서로 "주머니 안/밖" 상태 변화를 감지해 대체한다.
const MethodChannel _proximityMethodChannel = MethodChannel(
'proximity_monitor',
);
const EventChannel _proximityEventChannel = EventChannel(
'proximity_monitor/events',
);
const MethodChannel _proximityMethodChannel = MethodChannel('proximity_monitor');
const EventChannel _proximityEventChannel = EventChannel('proximity_monitor/events');
enum WatchMessageLevel { info, success, warning, error }
@@ -82,9 +78,9 @@ class NfcPocketCheckinController extends ChangeNotifier {
// 화면이 열려있는 동안은 실시간으로 위반 알림을 받아 팝업을 띄운다.
// 무단 반출이 한 번 감지되면 "신뢰된 감시 세션"은 끝난 것으로 보고,
// 다음 태깅은 체크아웃이 아니라 새 출석(재출석)으로 처리되도록 감시 상태를 해제한다.
_violationSub = FlutterBackgroundService()
.on('violation_detected')
.listen((event) {
_violationSub = FlutterBackgroundService().on('violation_detected').listen((
event,
) {
_isWatching = false;
notifyListeners();
onViolationDetected(event?['pocketNumber']?.toString());
@@ -95,9 +91,9 @@ class NfcPocketCheckinController extends ChangeNotifier {
) {
_isWatching = false;
_activePocketNumber = null;
_statusMessage = "폰을 회수했습니다. 수고하셨습니다!";
_statusMessage = "✅ 폰을 회수했습니다. 수고하셨습니다!";
notifyListeners();
onMessage("폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success);
onMessage("✅ 폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success);
});
}
}
@@ -121,7 +117,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
void _startNfcSession() async {
bool isAvailable = await NfcManager.instance.isAvailable();
if (!isAvailable) {
_statusMessage = "이 스마트폰은 NFC 기능이 꺼져있거나 지원되지 않습니다.";
_statusMessage = "❌ 이 스마트폰은 NFC 기능이 꺼져있거나 지원되지 않습니다.";
notifyListeners();
return;
}
@@ -138,7 +134,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
}
_isProcessing = true;
_statusMessage = "태그 인식 완료! 서버에 출석 전송 중...";
_statusMessage = "⏳ 태그 인식 완료! 서버에 출석 전송 중...";
notifyListeners();
try {
@@ -167,7 +163,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
tagUid: _getTagUid(tag),
);
} catch (e) {
onMessage("태그 읽기 실패: $e", WatchMessageLevel.error);
onMessage("❌ 태그 읽기 실패: $e", WatchMessageLevel.error);
} finally {
// 3초 후 연타 방지 해제 (쿨다운)
await Future.delayed(const Duration(seconds: 3));
@@ -220,16 +216,16 @@ class NfcPocketCheckinController extends ChangeNotifier {
onMessage("ℹ️ 이 기기는 백그라운드 감시를 지원하지 않습니다.", WatchMessageLevel.info);
}
} else {
onMessage("출석 실패: ${result['message']}", WatchMessageLevel.warning);
onMessage("⚠️ 출석 실패: ${result['message']}", WatchMessageLevel.warning);
}
} else {
onMessage(
"서버 에러 (코드: ${response.statusCode})",
"🚨 서버 에러 (코드: ${response.statusCode})",
WatchMessageLevel.error,
);
}
} catch (e) {
onMessage("서버 연결 실패: $e", WatchMessageLevel.error);
onMessage("❌ 서버 연결 실패: $e", WatchMessageLevel.error);
}
}
@@ -259,7 +255,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
_isWatching = true;
_activePocketNumber = pocketNumber;
_statusMessage = "백그라운드에서 감시 중입니다. 화면을 꺼도 계속 감시돼요.";
_statusMessage = "🛡️ 백그라운드에서 감시 중입니다. 화면을 꺼도 계속 감시돼요.";
notifyListeners();
}
@@ -272,9 +268,9 @@ class NfcPocketCheckinController extends ChangeNotifier {
}
_isWatching = false;
_activePocketNumber = null;
_statusMessage = "폰을 회수했습니다. 감시가 종료되었습니다.";
_statusMessage = "✅ 폰을 회수했습니다. 감시가 종료되었습니다.";
notifyListeners();
onMessage("감시가 종료되었습니다. 수고하셨습니다!", WatchMessageLevel.success);
onMessage("✅ 감시가 종료되었습니다. 수고하셨습니다!", WatchMessageLevel.success);
}
// -----------------------------------------------------------------------
@@ -295,13 +291,13 @@ class NfcPocketCheckinController extends ChangeNotifier {
_activePocketNumber = pocketNumber;
_isWatching = true;
_statusMessage = "주머니에 넣는 중...";
_statusMessage = "📥 주머니에 넣는 중...";
notifyListeners();
try {
await _proximityMethodChannel.invokeMethod('start');
} catch (e) {
onMessage("근접센서 감시 시작 실패: $e", WatchMessageLevel.error);
onMessage("❌ 근접센서 감시 시작 실패: $e", WatchMessageLevel.error);
_isWatching = false;
_isIosProximityWatch = false;
notifyListeners();
@@ -311,7 +307,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
// 학생이 폰을 주머니에 넣을 시간을 준 뒤, 그 시점 상태를 기준값으로 삼는다.
await Future.delayed(const Duration(seconds: 4));
_statusMessage = "감시 중입니다. 화면을 잠그지 말고 주머니에 넣어주세요.";
_statusMessage = "🛡️ 감시 중입니다. 화면을 잠그지 말고 주머니에 넣어주세요.";
notifyListeners();
_proximitySub = _proximityEventChannel.receiveBroadcastStream().listen((
@@ -363,9 +359,9 @@ class NfcPocketCheckinController extends ChangeNotifier {
if (isPermitted) {
_activePocketNumber = null;
_statusMessage = "폰을 회수했습니다. 수고하셨습니다!";
_statusMessage = "✅ 폰을 회수했습니다. 수고하셨습니다!";
notifyListeners();
onMessage("폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success);
onMessage("✅ 폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success);
return;
}
+9 -9
View File
@@ -37,19 +37,19 @@ class NfcTagWriterController extends ChangeNotifier {
Future<void> startWriteSession(String number) async {
if (number.isEmpty) {
onMessage("주머니 번호를 입력해주세요.", true);
onMessage("⚠️ 주머니 번호를 입력해주세요.", true);
return;
}
final String pocketLabel = "POCKET_$number";
bool isAvailable = await NfcManager.instance.isAvailable();
if (!isAvailable) {
onMessage("NFC가 꺼져있거나 지원되지 않습니다.", true);
onMessage("❌ NFC가 꺼져있거나 지원되지 않습니다.", true);
return;
}
_isWriting = true;
_statusMessage = "[$pocketLabel] 쓸 준비 완료! 스티커에 폰 뒷면을 대주세요.";
_statusMessage = "📡 [$pocketLabel] 쓸 준비 완료! 스티커에 폰 뒷면을 대주세요.";
notifyListeners();
NfcManager.instance.startSession(
@@ -58,11 +58,11 @@ class NfcTagWriterController extends ChangeNotifier {
try {
final ndef = Ndef.from(tag);
if (ndef == null) {
onMessage("이 태그는 NDEF 쓰기를 지원하지 않는 종류입니다.", true);
onMessage("❌ 이 태그는 NDEF 쓰기를 지원하지 않는 종류입니다.", true);
return;
}
if (!ndef.isWritable) {
onMessage("이 태그는 쓰기 잠금(read-only) 상태입니다.", true);
onMessage("❌ 이 태그는 쓰기 잠금(read-only) 상태입니다.", true);
return;
}
@@ -78,8 +78,8 @@ class NfcTagWriterController extends ChangeNotifier {
onMessage(
tagUid != null
? "[$pocketLabel] 쓰기 + UID 등록 성공!"
: "[$pocketLabel] 쓰기는 성공했지만 UID를 못 읽어 등록은 안 됐습니다.",
? "✅ [$pocketLabel] 쓰기 + UID 등록 성공!"
: "⚠️ [$pocketLabel] 쓰기는 성공했지만 UID를 못 읽어 등록은 안 됐습니다.",
tagUid == null,
);
// 다음 스티커를 연달아 쓰기 편하도록 번호를 자동으로 1 올려준다.
@@ -88,7 +88,7 @@ class NfcTagWriterController extends ChangeNotifier {
_statusMessage = "다음 번호를 확인하고 '쓰기 시작'을 다시 눌러주세요.";
notifyListeners();
} catch (e) {
onMessage("쓰기 실패: $e", true);
onMessage("❌ 쓰기 실패: $e", true);
} finally {
await NfcManager.instance.stopSession();
_isWriting = false;
@@ -117,7 +117,7 @@ class NfcTagWriterController extends ChangeNotifier {
body: jsonEncode({"tagUid": tagUid, "pocketNumber": pocketLabel}),
);
} catch (e) {
onMessage("서버에 UID 등록 실패: $e", true);
onMessage("❌ 서버에 UID 등록 실패: $e", true);
}
}
-70
View File
@@ -1,70 +0,0 @@
// 🔐 "로그인 유지" 기능. 로그인 성공 시 계정 정보를 기기에 저장해두고,
// 다음 실행 때 로그인 화면을 건너뛰고 바로 대시보드로 들어가게 한다.
// (서버에 별도 세션/토큰 개념이 없어서, 비밀번호는 저장하지 않고 신원 정보만 저장한다.)
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class SavedSession {
final String userId;
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() => {
'userId': userId,
'userName': userName,
'role': role,
'isDeviceMatched': isDeviceMatched,
'grade': grade,
};
factory SavedSession.fromJson(Map<String, dynamic> json) => SavedSession(
userId: json['userId'] as String,
userName: json['userName'] as String,
role: json['role'] as String,
isDeviceMatched: json['isDeviceMatched'] as bool? ?? true,
grade: json['grade'] as int?,
);
}
class SessionStore {
static const _key = 'saved_session_v1';
static Future<void> save(SavedSession session) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key, jsonEncode(session.toJson()));
} catch (_) {
// 저장 실패해도 로그인 자체는 계속 진행 (로그인 유지만 안 될 뿐).
}
}
static Future<SavedSession?> load() async {
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_key);
if (raw == null) return null;
return SavedSession.fromJson(jsonDecode(raw));
} catch (_) {
return null;
}
}
static Future<void> clear() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_key);
} catch (_) {
// 무시 - 어차피 로그아웃 화면으로는 이동한다.
}
}
}
@@ -18,13 +18,13 @@ class StudentDashboardController extends ChangeNotifier {
final response = await http.delete(url);
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
return (true, '${responseData['message']}');
return (true, '✅ ${responseData['message']}');
} else {
final errorData = jsonDecode(response.body);
return (false, '삭제 실패: ${errorData['detail'] ?? '알 수 없는 오류'}');
return (false, '❌ 삭제 실패: ${errorData['detail'] ?? '알 수 없는 오류'}');
}
} catch (e) {
return (false, '서버와 연결할 수 없습니다. (네트워크 에러)');
return (false, '❌ 서버와 연결할 수 없습니다. (네트워크 에러)');
} finally {
_isLoading = false;
notifyListeners();
-150
View File
@@ -1,150 +0,0 @@
// 📅 백품타 출석 달력(개인 스트릭 + 관리자 이벤트) 화면의 기능 담당 컨트롤러.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class StudyCalendarController extends ChangeNotifier {
final String studentId;
StudyCalendarController({required this.studentId});
bool _isLoading = true;
bool _isBusy = false;
int _currentStreak = 0;
int _longestStreak = 0;
Set<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;
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;
_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();
}
}
}
-125
View File
@@ -1,125 +0,0 @@
// 🍅 뽀모도로 루틴 공유(다른 학생과 공부/휴식 시간 조합 공유) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
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();
}
}
}
@@ -1,55 +0,0 @@
// 🏆 학년별 공부시간 랭킹 화면의 기능(서버 통신/상태) 담당 컨트롤러.
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();
}
}
}
-324
View File
@@ -1,324 +0,0 @@
// ⏱️ 우리 학교 전용 "백품타" 공부 타이머 화면의 기능(서버 통신/상태) 담당 컨트롤러.
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();
}
}
}
@@ -145,7 +145,7 @@ class TeacherAttendanceController extends ChangeNotifier {
await fetchAll();
return result['message'] ?? '하교 처리되었습니다.';
} catch (e) {
return '하교 처리 실패: $e';
return '❌ 하교 처리 실패: $e';
}
}
@@ -161,7 +161,7 @@ class TeacherAttendanceController extends ChangeNotifier {
await fetchAll();
return result['message'] ?? '처리되었습니다.';
} catch (e) {
return '반출 허용 실패: $e';
return '❌ 반출 허용 실패: $e';
}
}
@@ -177,7 +177,7 @@ class TeacherAttendanceController extends ChangeNotifier {
await fetchAll();
return result['message'] ?? '처리되었습니다.';
} catch (e) {
return '설정 실패: $e';
return '❌ 설정 실패: $e';
}
}
@@ -193,7 +193,7 @@ class TeacherAttendanceController extends ChangeNotifier {
await fetchAll();
return result['message'] ?? '처리되었습니다.';
} catch (e) {
return '출석시간 설정 실패: $e';
return '❌ 출석시간 설정 실패: $e';
}
}
@@ -207,7 +207,7 @@ class TeacherAttendanceController extends ChangeNotifier {
await fetchAll();
return result['message'] ?? '처리되었습니다.';
} catch (e) {
return '초기화 실패: $e';
return '❌ 초기화 실패: $e';
}
}
-124
View File
@@ -1,124 +0,0 @@
// 📍 선생님 호출(학생용) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
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();
}
}
}
@@ -1,97 +0,0 @@
// 📍 선생님 위치 등록(교사용) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
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();
}
}
}
@@ -36,13 +36,13 @@ class TeacherRegisterController extends ChangeNotifier {
final res = jsonDecode(response.body);
if (response.statusCode == 200 && res['status'] == 'success') {
return (true, '${res['message']}');
return (true, '✅ ${res['message']}');
} else {
String errorMsg = res['detail'] ?? res['message'] ?? '회원가입에 실패했습니다.';
return (false, errorMsg);
return (false, '❌ $errorMsg');
}
} catch (e) {
return (false, '서버와 통신에 실패했습니다.');
return (false, '❌ 서버와 통신에 실패했습니다.');
} finally {
_isLoading = false;
notifyListeners();
@@ -76,11 +76,11 @@ class TeacherStudentManagementController extends ChangeNotifier {
);
if (response.statusCode == 200) {
await fetchStudents();
return (true, '$studentName 학생 기기 초기화 완료! (비밀번호도 1234로 초기화됨)');
return (true, '✅ $studentName 학생 기기 초기화 완료! (비밀번호도 1234로 초기화됨)');
}
return (false, '초기화 통신 실패');
return (false, '❌ 초기화 통신 실패');
} catch (e) {
return (false, '초기화 통신 실패: $e');
return (false, '❌ 초기화 통신 실패: $e');
} finally {
_isWorking = false;
notifyListeners();
@@ -211,12 +211,12 @@ class TeacherStudentManagementController extends ChangeNotifier {
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
return (true, '${result['message'] ?? '계정이 생성되었습니다.'}');
return (true, '✅ ${result['message'] ?? '계정이 생성되었습니다.'}');
} else {
return (false, '생성 실패: ${result['message'] ?? '오류 발생'}');
return (false, '❌ 생성 실패: ${result['message'] ?? '오류 발생'}');
}
} catch (e) {
return (false, '네트워크 에러: $e');
return (false, '🚨 네트워크 에러: $e');
}
}
@@ -238,12 +238,12 @@ class TeacherStudentManagementController extends ChangeNotifier {
if (response.statusCode == 200 && result['status'] == 'success') {
await fetchStudents();
return (true, '${result['message'] ?? '학년이 지정되었습니다.'}');
return (true, '✅ ${result['message'] ?? '학년이 지정되었습니다.'}');
} else {
return (false, '지정 실패: ${result['message'] ?? '오류 발생'}');
return (false, '❌ 지정 실패: ${result['message'] ?? '오류 발생'}');
}
} catch (e) {
return (false, '네트워크 에러: $e');
return (false, '🚨 네트워크 에러: $e');
} finally {
_isWorking = false;
notifyListeners();
@@ -266,12 +266,12 @@ class TeacherStudentManagementController extends ChangeNotifier {
if (response.statusCode == 200) {
final resData = jsonDecode(utf8.decode(response.bodyBytes));
await fetchStudents();
return (true, '${resData['message']}');
return (true, '✅ ${resData['message']}');
} else {
return (false, '삭제 실패하였습니다.');
return (false, '❌ 삭제 실패하였습니다.');
}
} catch (e) {
return (false, '서버 에러 발생');
return (false, '❌ 서버 에러 발생');
} finally {
_isWorking = false;
notifyListeners();
+2 -83
View File
@@ -6,11 +6,8 @@ import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'config.dart';
import 'function/session_store.dart';
import 'pocket_watch_service.dart';
import 'theme/app_palette.dart';
import 'ui/login_screen.dart';
import 'ui/main_dashboard.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -22,18 +19,6 @@ 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});
@@ -42,74 +27,8 @@ class SchoolAttendanceApp extends StatelessWidget {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: '$schoolName 학생 도우미',
scrollBehavior: _NoGlowScrollBehavior(),
theme: ThemeData(
useMaterial3: true,
scaffoldBackgroundColor: AppPalette.mist,
colorScheme:
ColorScheme.fromSeed(
seedColor: AppPalette.ink,
brightness: Brightness.light,
).copyWith(
primary: AppPalette.ink,
onPrimary: AppPalette.paper,
secondary: AppPalette.ink,
onSecondary: AppPalette.paper,
surface: AppPalette.paper,
onSurface: AppPalette.ink,
),
),
home: const _StartupGate(), // 🚪 저장된 로그인이 있으면 대시보드로, 없으면 로그인 화면으로.
theme: ThemeData(primarySwatch: Colors.indigo, useMaterial3: true),
home: const LoginScreen(), // 🚪 앱을 켜면 무조건 로그인 화면이 먼저 등장합니다.
);
}
}
/// 🔐 "로그인 유지" 진입점. 기기에 저장된 세션이 있는지 확인하는 동안 잠깐 로딩을 보여주고,
/// 있으면 로그인 화면 없이 바로 대시보드로, 없으면 로그인 화면으로 보낸다.
class _StartupGate extends StatefulWidget {
const _StartupGate();
@override
State<_StartupGate> createState() => _StartupGateState();
}
class _StartupGateState extends State<_StartupGate> {
SavedSession? _session;
bool _checked = false;
@override
void initState() {
super.initState();
_checkSession();
}
Future<void> _checkSession() async {
final session = await SessionStore.load();
if (!mounted) return;
setState(() {
_session = session;
_checked = true;
});
}
@override
Widget build(BuildContext context) {
if (!_checked) {
return Scaffold(
backgroundColor: AppPalette.mist,
body: const Center(child: CircularProgressIndicator()),
);
}
final session = _session;
if (session != null) {
return MainDashboard(
userId: session.userId,
userName: session.userName,
role: session.role,
isDeviceMatched: session.isDeviceMatched,
grade: session.grade,
);
}
return const LoginScreen();
}
}
-62
View File
@@ -1,62 +0,0 @@
// 🧩 대시보드 타일의 격자 위치/크기(1x1 ~ 4x4)를 표현하는 값 객체.
// 전체 배치 캔버스는 가로 6칸 × 세로 4칸으로 고정한다.
// 📱 [기능 보류] 데스크탑에서는 잘 나오지만, 폰처럼 화면이 작은 기기에서는
// 6칸 고정 격자 때문에 타일이 너무 작아져 레이아웃이 깨진다.
// 작은 화면 대응 방안을 마련하기 전까지 편집 기능을 임시로 꺼둔다.
// (다시 켜려면 이 값만 true로 바꾸면 됨 — 관련 코드는 그대로 남겨둠)
const bool kTileEditingEnabled = false;
const int kGridCols = 6;
const int kGridRows = 4;
const int kMinTileSize = 1;
const int kMaxTileSize = 4;
class TileRect {
final int x;
final int y;
final int w;
final int h;
const TileRect({
required this.x,
required this.y,
required this.w,
required this.h,
});
factory TileRect.fromJson(Map<String, dynamic> json) => TileRect(
x: (json['x'] as num).toInt(),
y: (json['y'] as num).toInt(),
w: (json['w'] as num).toInt(),
h: (json['h'] as num).toInt(),
);
Map<String, dynamic> toJson(String id) => {
'id': id,
'x': x,
'y': y,
'w': w,
'h': h,
};
TileRect copyWith({int? x, int? y, int? w, int? h}) =>
TileRect(x: x ?? this.x, y: y ?? this.y, w: w ?? this.w, h: h ?? this.h);
bool overlaps(TileRect other) {
return x < other.x + other.w &&
x + w > other.x &&
y < other.y + other.h &&
y + h > other.y;
}
bool get isWithinCanvas =>
x >= 0 &&
y >= 0 &&
x + w <= kGridCols &&
y + h <= kGridRows &&
w >= kMinTileSize &&
h >= kMinTileSize &&
w <= kMaxTileSize &&
h <= kMaxTileSize;
}
+8 -8
View File
@@ -44,7 +44,7 @@ Future<void> initializePocketWatchService() async {
autoStartOnBoot: false,
isForegroundMode: true,
notificationChannelId: pocketWatchNotificationChannelId,
initialNotificationTitle: '주머니 감시 대기 중',
initialNotificationTitle: '🛡️ 주머니 감시 대기 중',
initialNotificationContent: '출석 태깅을 기다리는 중입니다.',
foregroundServiceNotificationId: pocketWatchNotificationId,
foregroundServiceTypes: [AndroidForegroundType.specialUse],
@@ -88,7 +88,7 @@ void onPocketWatchServiceStart(ServiceInstance service) {
Future<void> reportViolation() async {
// 진동 3연타로 강하게 경고 (백그라운드 서비스에서는 HapticFeedback이 아닌 vibration 패키지를 써야 동작함)
Vibration.vibrate(pattern: [0, 400, 150, 400, 150, 400]);
updateNotification("무단 반출 감지!", "[$pocketNumber] 주머니에서 폰이 감지되지 않습니다.");
updateNotification("🚨 무단 반출 감지!", "[$pocketNumber] 주머니에서 폰이 감지되지 않습니다.");
service.invoke('violation_detected', {
"pocketNumber": pocketNumber,
"timestamp": DateTime.now().toIso8601String(),
@@ -128,7 +128,7 @@ void onPocketWatchServiceStart(ServiceInstance service) {
}
if (isPermitted) {
updateNotification("폰 회수 완료", "[$pocketNumber] 주머니에서 정상적으로 회수되었습니다.");
updateNotification("✅ 폰 회수 완료", "[$pocketNumber] 주머니에서 정상적으로 회수되었습니다.");
service.invoke('checked_out', {"pocketNumber": pocketNumber});
service.stopSelf();
} else {
@@ -145,7 +145,7 @@ void onPocketWatchServiceStart(ServiceInstance service) {
baselineLux =
calibrationSamples.reduce((a, b) => a + b) ~/
calibrationSamples.length;
updateNotification("감시 중", "[$pocketNumber] 폰이 주머니 안에 있는지 감시 중");
updateNotification("🛡️ 감시 중", "[$pocketNumber] 폰이 주머니 안에 있는지 감시 중");
}
return;
}
@@ -163,22 +163,22 @@ void onPocketWatchServiceStart(ServiceInstance service) {
baselineLux = null;
violated = false;
updateNotification("주머니에 넣는 중...", "잠시 후 감시가 시작됩니다.");
updateNotification("📥 주머니에 넣는 중...", "잠시 후 감시가 시작됩니다.");
await Future.delayed(_placePhoneDelay);
updateNotification("밝기 측정 중...", "주머니 속 밝기를 기준값으로 설정하는 중입니다.");
updateNotification("🔎 밝기 측정 중...", "주머니 속 밝기를 기준값으로 설정하는 중입니다.");
try {
await Light().requestAuthorization();
lightSub = Light().lightSensorStream.listen(
onLightReading,
onError: (Object error) {
updateNotification("조도 센서 오류", "$error");
updateNotification("❌ 조도 센서 오류", "$error");
},
);
} catch (e) {
updateNotification("조도 센서 시작 실패", "$e");
updateNotification("❌ 조도 센서 시작 실패", "$e");
}
}
+12 -9
View File
@@ -3,8 +3,6 @@ import 'dart:convert';
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/login_screen.dart';
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
@@ -23,7 +21,9 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
Future<void> _updatePassword() async {
String newPw = _pwController.text.trim();
if (newPw.isEmpty || newPw == "1234") {
AppNotice.show(context, '초기 비밀번호와 다른 안전한 비밀번호를 입력하세요.');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('⚠️ 초기 비밀번호와 다른 안전한 비밀번호를 입력하세요.')),
);
return;
}
@@ -37,7 +37,9 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
final res = jsonDecode(response.body);
if (response.statusCode == 200 && res['status'] == 'success') {
AppNotice.show(context, '비밀번호 변경 완료! 다시 로그인해 주세요.');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('🔒 비밀번호 변경 완료! 다시 로그인해 주세요.')),
);
// 비밀번호를 바꿨으니 다시 로그인 화면으로 강제 이동
Navigator.pushReplacement(
context,
@@ -45,7 +47,9 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
);
}
} catch (e) {
AppNotice.show(context, '통신 실패');
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('❌ 통신 실패')));
} finally {
setState(() => _isLoading = false);
}
@@ -54,7 +58,6 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppPalette.mist,
body: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
@@ -62,7 +65,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'보안을 위해\n비밀번호를 변경해 주세요',
'🔒 보안을 위해\n비밀번호를 변경해 주세요',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
@@ -89,8 +92,8 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
height: 50,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
),
onPressed: _isLoading ? null : _updatePassword,
child: _isLoading
-14
View File
@@ -1,14 +0,0 @@
// 🎨 앱 전역 무채색 팔레트 (coolors.co: 1c1c1c-daddd8-ecebe4-eef0f2-fafaff).
// 배경/카드/배너 같은 구조적 색상은 전부 이 5색으로 통일한다.
// 삭제/위반/거절처럼 사용자가 즉시 알아채야 하는 경고성 색상(빨강 등)만 예외로 유지한다.
import 'package:flutter/material.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); // 카드 표면(거의 흰색)
}
+9 -19
View File
@@ -2,10 +2,7 @@
// 서버 통신/상태는 lib/function/admin_controller.dart가 담당한다.
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 초기화 암호 파라미터 보정 완료)
@@ -29,7 +26,9 @@ class _AdminDashboardState extends State<AdminDashboard> {
Future<void> _resetDatabase() async {
final message = await _controller.resetDatabase();
if (!mounted) return;
AppNotice.show(context, message);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
void _showResetConfirmDialog() {
@@ -38,7 +37,7 @@ class _AdminDashboardState extends State<AdminDashboard> {
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text(
'DB 초기화 경고',
'⚠️ DB 초기화 경고',
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
content: const Text(
@@ -72,13 +71,10 @@ class _AdminDashboardState extends State<AdminDashboard> {
listenable: _controller,
builder: (context, _) {
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill('관리자 시스템'),
title: const Text('🛠️ 관리자 시스템'),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
actions: [
IconButton(
icon: const Icon(Icons.logout),
@@ -92,23 +88,18 @@ 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: [
const Icon(
Icons.admin_panel_settings,
size: 100,
color: AppPalette.ink,
color: Colors.orange,
),
const SizedBox(height: 20),
const Text(
'데이터베이스 관리',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 40),
@@ -144,7 +135,6 @@ class _AdminDashboardState extends State<AdminDashboard> {
),
),
),
),
);
},
);
-171
View File
@@ -1,171 +0,0 @@
// 🔔 스낵바 대신 쓰는 알약(pill) 모양 알림. 기능 타일을 누를 때 뜨는
// "OO로 이동합니다" 같은 짧은 안내용. 푸시 알림(FCM)과는 무관 — 그건 그대로 동작한다.
// 웹에서는 왼쪽 위(알림창 자리)에, 폰 앱에서는 기존 스낵바처럼 아래쪽에 뜬다.
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../theme/app_palette.dart';
class AppNotice {
static final List<_QueuedNotice> _queue = [];
static bool _showing = false;
static void show(
BuildContext context,
String message, {
IconData? icon,
Color? color,
}) {
_queue.add(_QueuedNotice(context, message, icon, color));
_tryShowNext();
}
static void _tryShowNext() {
if (_showing || _queue.isEmpty) return;
final next = _queue.removeAt(0);
if (!next.context.mounted) {
_tryShowNext();
return;
}
_showing = true;
final overlay = Overlay.of(next.context);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (context) => _NoticeBanner(
message: next.message,
icon: next.icon,
color: next.color,
onDone: () {
entry.remove();
_showing = false;
_tryShowNext();
},
),
);
overlay.insert(entry);
}
}
class _QueuedNotice {
final BuildContext context;
final String message;
final IconData? 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,
});
@override
State<_NoticeBanner> createState() => _NoticeBannerState();
}
class _NoticeBannerState extends State<_NoticeBanner>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
reverseDuration: const Duration(milliseconds: 180),
);
_controller.forward();
Future.delayed(const Duration(milliseconds: 2600), () async {
if (!mounted) return;
await _controller.reverse();
widget.onDone();
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final curved = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
final media = MediaQuery.of(context);
final pill = Material(
color: Colors.transparent,
child: Container(
constraints: const BoxConstraints(maxWidth: 360),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
decoration: BoxDecoration(
color: widget.color ?? AppPalette.ink,
borderRadius: BorderRadius.circular(999),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.icon != null) ...[
Icon(widget.icon, color: AppPalette.paper, size: 18),
const SizedBox(width: 8),
],
Flexible(
child: Text(
widget.message,
style: const TextStyle(
color: AppPalette.paper,
fontSize: 13.5,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
);
final animatedPill = FadeTransition(
opacity: curved,
child: SlideTransition(
position: Tween<Offset>(
begin: kIsWeb ? const Offset(0, -0.3) : const Offset(0, 0.3),
end: Offset.zero,
).animate(curved),
child: pill,
),
);
if (kIsWeb) {
return Positioned(
top: media.padding.top + 16,
left: 16,
child: IgnorePointer(child: animatedPill),
);
}
return Positioned(
left: 16,
right: 16,
bottom: media.padding.bottom + 24,
child: IgnorePointer(child: Center(child: animatedPill)),
);
}
}
-303
View File
@@ -1,303 +0,0 @@
// 🗣️ 커뮤니티 게시판(백판) 홈 화면 (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 '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: 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),
),
);
},
),
);
},
);
}
}
-399
View File
@@ -1,399 +0,0 @@
// 💬 커뮤니티 게시판 - 게시글 상세 + 댓글 화면 (UI 전용).
// 서버 통신/상태는 lib/function/board_post_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/board_post_controller.dart';
import '../theme/app_palette.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: 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(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('삭제', style: TextStyle(color: Colors.red)),
),
],
),
);
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,
),
],
),
),
);
}
}
-169
View File
@@ -1,169 +0,0 @@
// 🚩 커뮤니티 게시판 - 신고 검토(운영자용) 화면 (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: Colors.redAccent,
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: Colors.red[400],
foregroundColor: Colors.white,
),
onPressed: _controller.isBusy
? null
: () => _resolve(reportId, 'delete'),
child: const Text('삭제'),
),
),
],
),
],
),
);
},
),
),
);
},
);
}
}
-138
View File
@@ -1,138 +0,0 @@
// ✍️ 커뮤니티 게시판 - 글쓰기 화면 (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),
),
),
),
],
),
),
);
},
);
}
}
-62
View File
@@ -1,62 +0,0 @@
// ⚙️ 대시보드 설정 화면 (UI 전용). 지금은 "UI 편집" 진입점 하나만 있다.
// "UI 편집"을 누르면 이 화면을 pop('edit_ui')로 닫고, 대시보드가 그 결과를 받아 편집 모드로 들어간다.
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});
@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('설정'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Container(
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
),
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
enabled: kTileEditingEnabled,
leading: const Icon(
Icons.grid_view_rounded,
color: AppPalette.ink,
),
title: const Text(
'UI 편집',
style: TextStyle(fontWeight: FontWeight.bold),
),
// 📱 [기능 보류] 작은 화면에서 레이아웃이 깨지는 문제로 잠시 막아둠.
subtitle: Text(
kTileEditingEnabled
? '타일을 드래그해서 옮기고 크기를 바꿀 수 있어요.'
: '작은 화면 대응을 준비 중이라 잠시 사용할 수 없어요.',
),
trailing: kTileEditingEnabled
? const Icon(Icons.chevron_right_rounded)
: null,
onTap: kTileEditingEnabled
? () => Navigator.pop(context, 'edit_ui')
: null,
),
),
],
),
);
}
}
-507
View File
@@ -1,507 +0,0 @@
// 📋 선생님용 "스마트기기 반출 대장" 화면 (UI 전용). 오늘 신청된 반출 요청을 보여주고 승인/거절한다.
// 서버 통신/상태는 lib/function/device_checkout_ledger_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/device_checkout_ledger_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class DeviceCheckoutLedgerPage extends StatefulWidget {
final String? teacherId;
final String? teacherName;
const DeviceCheckoutLedgerPage({super.key, this.teacherId, this.teacherName});
@override
State<DeviceCheckoutLedgerPage> createState() =>
_DeviceCheckoutLedgerPageState();
}
class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
final DeviceCheckoutLedgerController _controller =
DeviceCheckoutLedgerController();
@override
void initState() {
super.initState();
_controller.init();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _approve(int id) async {
final (_, message) = await _controller.approve(
id,
teacherId: widget.teacherId ?? '간편인증',
teacherName: widget.teacherName ?? '간편인증 선생',
);
if (!mounted) return;
AppNotice.show(context, message);
}
Future<void> _reject(int id) async {
final (_, message) = await _controller.reject(id);
if (!mounted) return;
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(
style: ElevatedButton.styleFrom(
backgroundColor: 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: Colors.orange),
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: Colors.orange,
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: Colors.red,
side: const BorderSide(color: Colors.red),
),
child: const Text('거절'),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
onPressed: _controller.isWorking
? null
: () {
Navigator.pop(dialogContext);
_approve(r['id']);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
child: const Text('승인'),
),
),
],
),
],
);
},
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('닫기'),
),
],
),
);
}
// 🖐️ [하드웨어 자동 감지 전까지 임시] 기기를 실제로 돌려받았을 때 누르는 버튼.
Future<void> _confirmReturn(int id) async {
final (_, message) = await _controller.confirmReturn(id);
if (!mounted) return;
AppNotice.show(context, message);
}
({Color color, String label}) _statusInfo(String status) {
switch (status) {
case 'PENDING':
return (color: Colors.orange, label: '대기중');
case 'APPROVED':
return (color: Colors.blue, label: '승인됨');
case 'RETURNED':
return (color: Colors.grey, label: '반납완료');
case 'REJECTED':
return (color: Colors.red, label: '거절됨');
default:
return (color: Colors.grey, label: status);
}
}
String _timeRange(String start, String end) {
// "YYYY-MM-DD HH:MM:SS" -> "HH:MM"
String hm(String full) => full.length >= 16 ? full.substring(11, 16) : full;
return '${hm(start)} ~ ${hm(end)}';
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final requests = _controller.requests;
final pendingCount = requests
.where((r) => r['status'] == 'PENDING')
.length;
final longCount = _controller.longPendingRequests.length;
return 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.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: Colors.red,
child: const Icon(Icons.warning_amber_rounded),
),
onPressed: _showLongRequestsDialog,
tooltip: '많은 시간요청 학생',
),
],
),
body: _controller.isLoading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
if (pendingCount > 0)
Container(
width: double.infinity,
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.orange[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.orange[400]!),
),
child: Text(
'승인 대기 중인 요청이 $pendingCount건 있습니다.',
style: TextStyle(
color: Colors.orange[900],
fontWeight: FontWeight.bold,
),
),
),
Expanded(
child: requests.isEmpty
? const Center(
child: Text(
'오늘 신청된 반출 요청이 없습니다.',
style: TextStyle(color: Colors.grey),
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
itemCount: requests.length,
itemBuilder: (context, index) {
final r = requests[index];
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,
color: AppPalette.paper,
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: isLong
? BorderSide(
color: Colors.red[400]!,
width: 2,
)
: (isPending
? BorderSide(
color: Colors.orange[300]!,
)
: BorderSide(
color: AppPalette.sage,
)),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'${r['studentName']} (${r['studentId']})',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
Container(
padding:
const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: info.color.withValues(
alpha: 0.12,
),
borderRadius:
BorderRadius.circular(20),
),
child: Text(
info.label,
style: TextStyle(
color: info.color,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 6),
Text(
_timeRange(
r['requestedStart'],
r['requestedEnd'],
),
style: TextStyle(
color: Colors.grey[700],
fontSize: 13,
),
),
const SizedBox(height: 2),
Text(
r['purpose'],
style: TextStyle(
color: Colors.grey[700],
fontSize: 13,
),
),
if (status == 'APPROVED' &&
r['approvedByTeacherName'] !=
null) ...[
const SizedBox(height: 2),
Text(
'${r['approvedByTeacherName']} 선생님이 허용했습니다',
style: TextStyle(
color: Colors.blue[700],
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
if (isPending) ...[
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed:
_controller.isWorking
? null
: () => _reject(r['id']),
style:
OutlinedButton.styleFrom(
foregroundColor:
Colors.red,
side: const BorderSide(
color: Colors.red,
),
),
child: const Text('거절'),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
onPressed:
_controller.isWorking
? null
: () => _approve(r['id']),
style:
ElevatedButton.styleFrom(
backgroundColor:
AppPalette.ink,
foregroundColor:
AppPalette.paper,
),
child: const Text('승인'),
),
),
],
),
],
if (status == 'APPROVED') ...[
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _controller.isWorking
? null
: () =>
_confirmReturn(r['id']),
style: OutlinedButton.styleFrom(
foregroundColor:
Colors.grey[800],
side: BorderSide(
color: Colors.grey[400]!,
),
),
icon: const Icon(
Icons
.assignment_turned_in_outlined,
size: 18,
),
label: const Text('기기 반납 확인'),
),
),
],
],
),
),
);
},
),
),
],
),
);
},
);
}
}
-197
View File
@@ -1,197 +0,0 @@
// 📱 스마트기기(패드) 반출 신청 화면 (UI 전용). 시작~종료 시각과 사용 목적을 입력받는다.
// 서버 통신/상태는 lib/function/device_checkout_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/device_checkout_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class DeviceCheckoutRequestScreen extends StatefulWidget {
final String studentId;
final String studentName;
const DeviceCheckoutRequestScreen({
super.key,
required this.studentId,
required this.studentName,
});
@override
State<DeviceCheckoutRequestScreen> createState() =>
_DeviceCheckoutRequestScreenState();
}
class _DeviceCheckoutRequestScreenState
extends State<DeviceCheckoutRequestScreen> {
final DeviceCheckoutController _controller = DeviceCheckoutController();
final TextEditingController _purposeController = TextEditingController();
TimeOfDay? _startTime;
TimeOfDay? _endTime;
@override
void dispose() {
_controller.dispose();
_purposeController.dispose();
super.dispose();
}
String _formatTime(TimeOfDay time) =>
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
Future<void> _pickStartTime() async {
final picked = await showTimePicker(
context: context,
initialTime: _startTime ?? TimeOfDay.now(),
helpText: '사용 시작 시각',
);
if (picked != null) setState(() => _startTime = picked);
}
Future<void> _pickEndTime() async {
final picked = await showTimePicker(
context: context,
initialTime: _endTime ?? TimeOfDay.now(),
helpText: '사용 종료 시각',
);
if (picked != null) setState(() => _endTime = picked);
}
Future<void> _submit() async {
final purpose = _purposeController.text.trim();
if (_startTime == null || _endTime == null || purpose.isEmpty) {
AppNotice.show(context, '시작/종료 시각과 사용 목적을 모두 입력해주세요.');
return;
}
final (success, message) = await _controller.submitRequest(
studentId: widget.studentId,
studentName: widget.studentName,
purpose: purpose,
startTime: _formatTime(_startTime!),
endTime: _formatTime(_endTime!),
);
if (!mounted) return;
AppNotice.show(context, message);
if (success) Navigator.pop(context);
}
@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: 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: [
const Text(
'패드는 NFC 태그가 안 되니, 사용 시간과 목적을 적어 신청하면\n'
'선생님이 확인하고 승인해줍니다. 승인되면 반납 바구니에서 꺼내 쓰세요.',
style: TextStyle(
color: Colors.black54,
fontSize: 13,
height: 1.4,
),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppPalette.sage),
),
child: Column(
children: [
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _pickStartTime,
icon: const Icon(Icons.play_arrow_rounded),
label: Text(
_startTime == null
? '시작 시각'
: _formatTime(_startTime!),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _pickEndTime,
icon: const Icon(Icons.stop_rounded),
label: Text(
_endTime == null
? '종료 시각'
: _formatTime(_endTime!),
),
),
),
],
),
const SizedBox(height: 16),
TextField(
controller: _purposeController,
maxLines: 3,
decoration: const InputDecoration(
labelText: '사용 목적',
hintText: '예: 수행평가 자료조사',
alignLabelWithHint: true,
border: OutlineInputBorder(),
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _controller.isSubmitting
? null
: _submit,
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _controller.isSubmitting
? const CircularProgressIndicator(
color: Colors.white,
)
: const Text(
'반출 신청하기',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
),
],
),
),
],
),
),
),
),
);
},
);
}
}
-289
View File
@@ -1,289 +0,0 @@
// 🧑‍🤝‍🧑 백품타 친구 화면 (UI 전용). "실시간 출석 현황"과 같은 패밀리룩을 따른다.
// 서버 통신/상태는 lib/function/friend_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/friend_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class FriendsScreen extends StatefulWidget {
final String studentId;
final String studentName;
const FriendsScreen({
super.key,
required this.studentId,
required this.studentName,
});
@override
State<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,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '친구 학번',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('신청하기'),
),
],
),
);
if (result != true || idController.text.trim().isEmpty) return;
final (success, message) = await _controller.sendRequest(
idController.text.trim(),
);
if (!mounted) return;
AppNotice.show(
context,
message,
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
);
}
Future<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(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('삭제', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed != true) return;
final (_, message) = await _controller.removeFriend(friendId);
if (!mounted) return;
AppNotice.show(context, message);
}
Widget _sectionHeader(String title, {int? count}) {
return Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: AppPalette.ink,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppPalette.ink,
),
),
if (count != null) ...[
const SizedBox(width: 6),
Text(
'$count명',
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
),
],
],
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
),
floatingActionButton: FloatingActionButton.extended(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
onPressed: _showAddFriendDialog,
icon: const Icon(Icons.person_add_rounded),
label: const Text('친구 추가'),
),
body: _controller.isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _controller.refresh,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 90),
children: [
const Center(child: TitlePill('친구')),
const SizedBox(height: 20),
if (_controller.requests.isNotEmpty) ...[
_sectionHeader(
'받은 친구 신청',
count: _controller.requests.length,
),
const SizedBox(height: 10),
for (final r in _controller.requests)
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
),
child: Row(
children: [
Expanded(
child: Text(
'${r['name']} (${r['studentId']})',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
TextButton(
onPressed: () =>
_respond(r['requestId'] as int, false),
child: const Text(
'거절',
style: TextStyle(color: Colors.grey),
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () =>
_respond(r['requestId'] as int, true),
child: const Text('수락'),
),
],
),
),
const SizedBox(height: 20),
],
_sectionHeader('내 친구', count: _controller.friends.length),
const SizedBox(height: 10),
if (_controller.friends.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text(
'아직 친구가 없어요. 학번으로 친구를 추가해보세요.',
style: TextStyle(color: Colors.grey),
),
)
else
for (final f in _controller.friends)
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppPalette.ink.withValues(
alpha: 0.06,
),
shape: BoxShape.circle,
),
child: const Icon(
Icons.person_rounded,
size: 16,
color: AppPalette.ink,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'${f['name']} (${f['studentId']})',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(
Icons.person_remove_outlined,
color: Colors.grey,
),
onPressed: () =>
_remove(f['studentId'] as String),
),
],
),
),
],
),
),
);
},
);
}
}
-527
View File
@@ -1,527 +0,0 @@
// 👥 백품타 그룹 상세 화면 (UI 전용) - 멤버 오늘 출석 현황, 친구 초대, 그룹 나가기.
// 서버 통신/상태는 lib/function/group_detail_controller.dart, friend_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/friend_controller.dart';
import '../function/group_detail_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import '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: 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(
style: ElevatedButton.styleFrom(
backgroundColor: 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(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('나가기', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed != true) return;
final (success, message) = await _controller.leave();
if (!mounted) return;
AppNotice.show(context, message);
if (success) Navigator.of(context).pop();
}
String _formatMinutes(int seconds) {
final m = seconds ~/ 60;
if (m < 60) return '$m분';
return '${m ~/ 60}시간 ${m % 60}분';
}
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: 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: const 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: 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: 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;
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: [
Icon(
attended
? Icons.check_circle_rounded
: Icons.radio_button_unchecked_rounded,
color: attended
? Colors.white
: Colors.grey[350],
size: 18,
),
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(
(m['todaySeconds'] as num?)?.toInt() ?? 0,
),
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,
),
),
],
),
);
}
}
-448
View File
@@ -1,448 +0,0 @@
// 👥 백품타 그룹 스터디 목록 화면 (UI 전용). "실시간 출석 현황"과 같은 패밀리룩을 따른다.
// 서버 통신/상태는 lib/function/group_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/group_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'group_detail_screen.dart';
import 'launchpad_transition.dart';
import 'title_pill.dart';
class GroupListScreen extends StatefulWidget {
final String studentId;
final String studentName;
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,
maxLength: 20,
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: 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: AppPalette.ink,
foregroundColor: AppPalette.paper,
onPressed: _showCreateGroupDialog,
icon: const Icon(Icons.add_rounded),
label: const Text('그룹 만들기'),
),
body: _controller.isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _controller.refresh,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 90),
children: [
const Center(child: TitlePill('그룹 스터디')),
const SizedBox(height: 20),
if (_controller.invites.isNotEmpty) ...[
_sectionHeader(
'받은 그룹 초대',
count: _controller.invites.length,
),
const SizedBox(height: 10),
for (final inv in _controller.invites)
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
),
child: Row(
children: [
Expanded(
child: Text(
'${inv['groupName']}',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
TextButton(
onPressed: () => _respondInvite(
inv['inviteId'] as int,
false,
),
child: const Text(
'거절',
style: TextStyle(color: Colors.grey),
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () => _respondInvite(
inv['inviteId'] as int,
true,
),
child: const Text('참여'),
),
],
),
),
const SizedBox(height: 20),
],
_sectionHeader('내 그룹', count: _controller.groups.length),
const SizedBox(height: 10),
if (_controller.groups.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text(
'아직 그룹이 없어요. 새 그룹을 만들거나 초대를 기다려보세요.',
style: TextStyle(color: Colors.grey),
),
)
else
for (final g in _controller.groups)
GestureDetector(
onTap: () => _openGroup(g),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppPalette.ink.withValues(
alpha: 0.06,
),
shape: BoxShape.circle,
),
child: const Icon(
Icons.groups_rounded,
color: AppPalette.ink,
size: 18,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
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: Colors.red[50],
borderRadius:
BorderRadius.circular(20),
border: Border.all(
color: Colors.red,
),
),
child: Text(
'대기 ${g['pendingRequestCount']}',
style: const TextStyle(
fontSize: 10,
color: Colors.red,
),
),
),
],
],
),
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: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () =>
_requestJoin(g['groupId'] as int),
child: const Text('참여 신청'),
),
],
),
),
],
],
),
),
);
},
);
}
}
-115
View File
@@ -1,115 +0,0 @@
// 🚀 맥북 런치패드처럼 "앞에서 확대되며 나타나고, 뒤는 블러 처리되는" 전환 효과.
// 기존의 좌우 슬라이드 전환을 대체한다. 두 가지 쓰임새가 있다:
// 1) launchpadPageRoute — 전체 화면 이동(다른 기능 타일 진입)용. 전환 중에만 블러가 보이고,
// 화면이 다 뜨면 원래 화면(대시보드)은 완전히 가려진다.
// 2) showLaunchpadMenu — 설정/로그아웃 같은 작은 메뉴 오버레이용. 메뉴가 떠 있는 동안
// 뒤에 있는 대시보드가 계속 뿌옇게 보인다. 바깥을 탭하면 닫힌다.
import 'dart:ui';
import 'package:flutter/material.dart';
class LaunchpadPageRoute<T> extends PageRouteBuilder<T> {
LaunchpadPageRoute({required WidgetBuilder builder})
: super(
transitionDuration: const Duration(milliseconds: 320),
reverseTransitionDuration: const Duration(milliseconds: 240),
pageBuilder: (context, animation, secondaryAnimation) =>
builder(context),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return AnimatedBuilder(
animation: curved,
child: child,
builder: (context, child) {
final t = curved.value;
return Stack(
children: [
// 뒤에 있던 화면(대시보드)을 전환하는 동안만 뿌옇게 보여준다.
Positioned.fill(
child: IgnorePointer(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 22 * t,
sigmaY: 22 * t,
),
child: Container(
color: Colors.black.withValues(alpha: 0.12 * t),
),
),
),
),
Opacity(
opacity: t,
child: Transform.scale(
scale: 0.94 + 0.06 * t,
child: child,
),
),
],
);
},
);
},
);
}
/// 화면 전체 이동에 런치패드 전환 효과를 적용한다. MaterialPageRoute 대신 이걸 쓰면 됨.
Future<T?> pushLaunchpad<T>(BuildContext context, WidgetBuilder builder) {
return Navigator.push<T>(context, LaunchpadPageRoute<T>(builder: builder));
}
/// 설정/로그아웃 같은 작은 메뉴를 런치패드 스타일(확대+페이드, 배경은 계속 블러 유지)로 띄운다.
/// 바깥을 탭하면 닫힌다. builder는 화면 전체를 채우는 Align 등으로 메뉴 위치를 직접 잡아야 한다.
Future<T?> showLaunchpadMenu<T>({
required BuildContext context,
required WidgetBuilder builder,
}) {
return showGeneralDialog<T>(
context: context,
barrierDismissible: true,
barrierLabel: 'menu',
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 260),
pageBuilder: (context, animation, secondaryAnimation) => builder(context),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return AnimatedBuilder(
animation: curved,
child: child,
builder: (context, child) {
final t = curved.value;
return Stack(
children: [
// 메뉴가 떠 있는 동안 계속 뿌옇게 — 기능 화면이랑 헷갈리지 않게 진하게.
Positioned.fill(
child: IgnorePointer(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 32 * t, sigmaY: 32 * t),
child: Container(
color: Colors.black.withValues(alpha: 0.28 * t),
),
),
),
),
Opacity(
opacity: t,
child: Transform.scale(
alignment: Alignment.topRight,
scale: 0.85 + 0.15 * t,
child: child,
),
),
],
);
},
);
},
);
}
+61 -91
View File
@@ -3,10 +3,9 @@
import 'package:flutter/material.dart';
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 'main_dashboard.dart';
import 'student_dashboard.dart';
import 'teacher_dashboard.dart';
import 'admin_dashboard.dart';
import 'teacher_register_screen.dart';
// -------------------------------------------------------------
@@ -24,7 +23,6 @@ class _LoginScreenState extends State<LoginScreen> {
final LoginController _controller = LoginController();
final TextEditingController _idController = TextEditingController();
final TextEditingController _pwController = TextEditingController();
bool _keepLoggedIn = true;
@override
void dispose() {
@@ -46,27 +44,15 @@ class _LoginScreenState extends State<LoginScreen> {
_showErrorDialog(result.errorMessage!);
break;
case LoginOutcome.masterSuccess:
if (_keepLoggedIn) {
await SessionStore.save(
SavedSession(
userId: result.studentId!,
userName: result.name!,
role: 'student',
isDeviceMatched: true,
),
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('👑 개발자 최고 권한으로 로그인되었습니다.')),
);
} else {
await SessionStore.clear();
}
if (!mounted) return;
AppNotice.show(context, '개발자 최고 권한으로 로그인되었습니다.');
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => MainDashboard(
userId: result.studentId!,
userName: result.name!,
role: 'student',
builder: (context) => StudentDashboard(
studentId: result.studentId!,
studentName: result.name!,
isDeviceMatched: true,
),
),
@@ -78,17 +64,17 @@ class _LoginScreenState extends State<LoginScreen> {
result.name!,
result.role!,
result.isDeviceMatched,
result.grade,
);
break;
case LoginOutcome.success:
AppNotice.show(context, '${result.name}님 환영합니다!');
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('✅ ${result.name}님 환영합니다!')));
_navigateBasedOnRole(
result.role!,
result.studentId!,
result.name!,
result.isDeviceMatched,
result.grade,
);
break;
}
@@ -100,7 +86,6 @@ class _LoginScreenState extends State<LoginScreen> {
String name,
String role,
bool isDeviceMatched,
int? grade,
) {
final TextEditingController newPwController = TextEditingController();
@@ -114,7 +99,7 @@ class _LoginScreenState extends State<LoginScreen> {
builder: (context, setDialogState) {
return AlertDialog(
title: const Text(
'초기 비밀번호 변경',
'🔒 초기 비밀번호 변경',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: Column(
@@ -144,13 +129,17 @@ class _LoginScreenState extends State<LoginScreen> {
)
: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
),
onPressed: () async {
String newPassword = newPwController.text.trim();
if (newPassword.isEmpty) {
AppNotice.show(context, '새 비밀번호를 입력해 주세요.');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('⚠️ 새 비밀번호를 입력해 주세요.'),
),
);
return;
}
@@ -165,16 +154,19 @@ class _LoginScreenState extends State<LoginScreen> {
if (success) {
Navigator.pop(dialogContext);
AppNotice.show(context, message);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
_navigateBasedOnRole(
role,
studentId,
name,
isDeviceMatched,
grade,
);
} else {
AppNotice.show(context, message);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
},
child: const Text('변경하고 시작하기'),
@@ -188,47 +180,45 @@ class _LoginScreenState extends State<LoginScreen> {
}
// 🆕 권한별 화면 이동시 기기 일치 여부 파라미터(`isDeviceMatched`) 수신 및 대시보드 전달
Future<void> _navigateBasedOnRole(
void _navigateBasedOnRole(
String role,
String studentId,
String name,
bool isDeviceMatched,
int? grade,
) async {
if (_keepLoggedIn) {
await SessionStore.save(
SavedSession(
userId: studentId,
userName: name,
role: role,
isDeviceMatched: isDeviceMatched,
grade: grade,
),
);
} else {
await SessionStore.clear();
}
if (!mounted) return;
) {
if (role == 'teacher') {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => MainDashboard(
userId: studentId,
userName: name,
role: role,
builder: (context) =>
TeacherDashboard(teacherId: studentId, teacherName: name),
),
);
} else if (role == 'admin') {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const AdminDashboard()),
);
} else {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => StudentDashboard(
studentId: studentId,
studentName: name,
isDeviceMatched: isDeviceMatched,
grade: grade,
),
),
);
}
}
void _showErrorDialog(String message) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text(
'인증 실패',
'⚠️ 인증 실패',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: Text(message),
@@ -253,7 +243,7 @@ class _LoginScreenState extends State<LoginScreen> {
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: Text('$title 권한 인증 (기존 방식)'),
title: Text('🔒 $title 권한 인증 (기존 방식)'),
content: TextField(
controller: passwordController,
obscureText: true,
@@ -267,7 +257,9 @@ class _LoginScreenState extends State<LoginScreen> {
MaterialPageRoute(builder: (context) => nextPage),
);
} else {
AppNotice.show(context, '비밀번호가 올바르지 않습니다.');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('❌ 비밀번호가 올바르지 않습니다.')),
);
}
},
decoration: const InputDecoration(
@@ -289,7 +281,9 @@ class _LoginScreenState extends State<LoginScreen> {
MaterialPageRoute(builder: (context) => nextPage),
);
} else {
AppNotice.show(context, '비밀번호가 올바르지 않습니다.');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('❌ 비밀번호가 올바르지 않습니다.')),
);
}
},
child: const Text('인증하기'),
@@ -306,7 +300,7 @@ class _LoginScreenState extends State<LoginScreen> {
listenable: _controller,
builder: (context, _) {
return Scaffold(
backgroundColor: AppPalette.mist,
backgroundColor: Colors.grey[50],
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
@@ -315,14 +309,14 @@ class _LoginScreenState extends State<LoginScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.school, size: 80, color: AppPalette.ink),
const Icon(Icons.school, size: 80, color: Colors.indigo),
const SizedBox(height: 16),
Text(
'$schoolName 모니터',
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: AppPalette.ink,
color: Colors.indigo,
),
),
const SizedBox(height: 8),
@@ -352,31 +346,7 @@ class _LoginScreenState extends State<LoginScreen> {
prefixIcon: Icon(Icons.lock),
),
),
const SizedBox(height: 8),
InkWell(
onTap: () =>
setState(() => _keepLoggedIn = !_keepLoggedIn),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: _keepLoggedIn,
activeColor: AppPalette.ink,
onChanged: (value) =>
setState(() => _keepLoggedIn = value ?? true),
),
const Text(
'로그인 상태 유지',
style: TextStyle(color: AppPalette.ink),
),
],
),
),
),
const SizedBox(height: 16),
const SizedBox(height: 24),
_controller.isLoading
? const CircularProgressIndicator()
: SizedBox(
@@ -384,7 +354,7 @@ class _LoginScreenState extends State<LoginScreen> {
height: 55,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@@ -409,9 +379,9 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
child: const Text(
'선생님이신가요? 교사 회원가입 하기',
'👨‍🏫 선생님이신가요? 교사 회원가입 하기',
style: TextStyle(
color: AppPalette.ink,
color: Colors.indigo,
fontWeight: FontWeight.bold,
),
),
@@ -433,7 +403,7 @@ class _LoginScreenState extends State<LoginScreen> {
onPressed: () => _showLegacyPasswordDialog(
'선생님',
'1234',
const MainDashboard(role: 'teacher'),
const TeacherDashboard(),
),
),
TextButton.icon(
@@ -449,7 +419,7 @@ class _LoginScreenState extends State<LoginScreen> {
onPressed: () => _showLegacyPasswordDialog(
'시스템 관리자',
'4936',
const MainDashboard(role: 'admin'),
const AdminDashboard(),
),
),
],
File diff suppressed because it is too large Load Diff
+14 -15
View File
@@ -5,9 +5,6 @@ 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")
@@ -75,7 +72,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
builder: (context) => AlertDialog(
backgroundColor: Colors.red[50],
title: const Text(
"무단 반출 감지",
"🚨 무단 반출 감지",
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
content: Text(
@@ -101,7 +98,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text("감시 모드 켜기 전에"),
title: const Text("🛡️ 감시 모드 켜기 전에"),
content: const Text(
"iOS는 화면을 잠그면 감시가 끊겨요. 아래 순서로 '가이드 접근'을 먼저 켜주세요.\n\n"
"1. 사이드(또는 홈) 버튼 3번 빠르게 누르기\n"
@@ -130,9 +127,9 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("자습실 출석 완료!"),
title: const Text("🎉 자습실 출석 완료!"),
content: Text(
"${widget.studentName} 학생!\n[$pocketNumber] 주머니 출석이 확인되었습니다.\n\n폰을 주머니에 쏙 넣고 자습에 집중해 주세요!",
"${widget.studentName} 학생!\n[$pocketNumber] 주머니 출석이 확인되었습니다.\n\n폰을 주머니에 쏙 넣고 자습에 집중해 주세요! ✏️",
),
actions: [
TextButton(
@@ -146,7 +143,9 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
void _showSnackBar(String text, Color color) {
if (!mounted) return;
AppNotice.show(context, text, color: color);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(text), backgroundColor: color));
}
@override
@@ -160,11 +159,11 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill("자습실 NFC 출석체크"),
title: const Text(
"자습실 NFC 출석체크",
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
backgroundColor: const Color.fromARGB(255, 48, 48, 52),
),
body: Center(
child: Padding(
@@ -259,8 +258,8 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
const SizedBox(height: 8),
Text(
!kIsWeb && Platform.isIOS
? "가이드 접근을 켠 채로 화면만 잠기도록 두세요.\n(임의로 앱을 나가면 감시가 끊깁니다)\n감시를 끝내려면 가이드 접근을 먼저 끄고 다시 태깅하세요."
: "화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)",
? "🔒 가이드 접근을 켠 채로 화면만 잠기도록 두세요.\n(임의로 앱을 나가면 감시가 끊깁니다)\n감시를 끝내려면 가이드 접근을 먼저 끄고 다시 태깅하세요."
: "📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)",
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white38, fontSize: 13),
),
+11 -17
View File
@@ -2,9 +2,6 @@
// 버튼/입력폼을 그린다. NFC 세션/서버 통신은 lib/function/nfc_tag_writer_controller.dart가 담당한다.
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});
@@ -37,11 +34,11 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
void _showSnackBar(String text, bool isError) {
if (!mounted) return;
AppNotice.show(
context,
text,
color: isError ? Colors.red[700] : Colors.green[700],
icon: isError ? Icons.error_outline_rounded : Icons.check_circle_rounded,
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(text),
backgroundColor: isError ? Colors.red : Colors.green,
),
);
}
@@ -52,13 +49,10 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
builder: (context, _) {
final bool isWriting = _controller.isWriting;
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill("NFC 주머니 태그 쓰기"),
title: const Text("🏷️ NFC 주머니 태그 쓰기"),
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
body: Padding(
padding: const EdgeInsets.all(24.0),
@@ -79,7 +73,7 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
Icon(
isWriting ? Icons.nfc : Icons.edit_note_rounded,
size: 80,
color: isWriting ? Colors.orange : AppPalette.ink,
color: isWriting ? Colors.orange : Colors.deepPurple,
),
const SizedBox(height: 16),
Text(
@@ -93,8 +87,8 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
height: 55,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
onPressed: isWriting
? null
+511
View File
@@ -0,0 +1,511 @@
// 🎓 학생 대시보드 (UI 전용). NFC 주머니 체크인 진입, 실시간 학교 상황 안내,
// 개발자 마스터 계정 전용 관리 메뉴(현황/DB제어/계정관리/삭제) 카드를 그린다.
// 계정 삭제 서버 통신은 lib/function/student_dashboard_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/student_dashboard_controller.dart';
import 'nfc_poccket_checkin_screen.dart';
import 'login_screen.dart';
import 'teacher_attendance_page.dart';
import 'admin_dashboard.dart';
import 'teacher_student_management_page.dart';
import 'nfc_tag_writer_screen.dart';
// -------------------------------------------------------------
// 2. 학생 대시보드 (StudentDashboard)
// -------------------------------------------------------------
class StudentDashboard extends StatefulWidget {
final String studentId;
final String studentName;
final bool isDeviceMatched; // 🆕 [변경] 기기 UUID가 매칭되었는지 확인하는 변수 추가
const StudentDashboard({
super.key,
required this.studentId,
required this.studentName,
required this.isDeviceMatched, // 🆕 [변경] 필수 매개변수로 등록
});
@override
State<StudentDashboard> createState() => _StudentDashboardState();
}
class _StudentDashboardState extends State<StudentDashboard> {
final StudentDashboardController _controller = StudentDashboardController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
// 1️⃣ NFC 태그 카드 → 실제 NFC 주머니 체크인 화면으로 이동
void _openPocketCheckIn(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NfcPocketCheckInScreen(
studentId: widget.studentId,
studentName: widget.studentName,
),
),
);
}
// 2️⃣ [기존 동일] 마스터 계정 전용 회원 삭제 버튼 동작
Future<void> _deleteUser(String userId) async {
final (_, message) = await _controller.deleteUser(userId);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// 3️⃣ [기존 동일] 학번/교직원 번호를 입력받는 모던 팝업창(Dialog)
void _showDeleteUserDialog(BuildContext context) {
final TextEditingController idController = TextEditingController();
showDialog(
context: context,
builder: (context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.redAccent),
SizedBox(width: 10),
Text(
'계정 강제 삭제',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'학생의 학번 또는 교사의 교직원 번호를 입력하세요.\nDB에서 해당 계정과 토큰이 즉시 삭제됩니다.',
style: TextStyle(
color: Colors.black54,
fontSize: 13,
height: 1.4,
),
),
const SizedBox(height: 16),
TextField(
controller: idController,
decoration: InputDecoration(
labelText: '학번 또는 교직원 번호',
hintText: '예: 201101 또는 T1001',
labelStyle: TextStyle(color: Colors.red[400]),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: Colors.red[400]!, width: 2),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
),
prefixIcon: const Icon(Icons.person_remove_alt_1_rounded),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'취소',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.bold,
),
),
),
ElevatedButton(
onPressed: () {
final inputId = idController.text.trim();
if (inputId.isNotEmpty) {
Navigator.pop(context);
_deleteUser(inputId);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: const Text(
'삭제 실행',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final bool isDeveloper = widget.studentId == "2061";
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: Text(
isDeveloper ? '👑 MASTER CONTROL' : '🎓 STUDENT PORTAL',
style: const TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
centerTitle: true,
backgroundColor: isDeveloper
? Colors.deepPurple[700]
: Colors.indigo[700],
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.logout_rounded),
onPressed: () => Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
),
),
],
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 28,
),
decoration: BoxDecoration(
color: isDeveloper
? Colors.deepPurple[700]
: Colors.indigo[700],
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(32),
bottomRight: Radius.circular(32),
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: CircleAvatar(
radius: 30,
backgroundColor: isDeveloper
? Colors.deepPurple[50]
: Colors.indigo[50],
child: Icon(
isDeveloper
? Icons.admin_panel_settings_rounded
: Icons.school_rounded,
size: 32,
color: isDeveloper
? Colors.deepPurple
: Colors.indigo,
),
),
),
const SizedBox(width: 18),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${widget.studentName} 님',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
isDeveloper
? '최고 관리 권한 활성화됨'
: '학번: ${widget.studentId} | 인증 완료',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
),
),
],
),
],
),
),
const SizedBox(height: 32),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Row(
children: [
Container(
width: 4,
height: 18,
decoration: BoxDecoration(
color: isDeveloper
? Colors.deepPurple
: Colors.indigo,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 10),
const Text(
'스마트 관리 시스템 메뉴',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
],
),
),
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, constraints) {
// 🖥️ 웹(넓은 화면)은 한 줄에 5개씩, 폰(좁은 화면)은 기존 2개 그대로.
final bool isWide = constraints.maxWidth >= 800;
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isWide ? 1300 : 700,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: isWide ? 5 : 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.95,
children: [
// 🆕 [변경 지점] 기기가 일치하면 정상 활성화, 일치하지 않으면 자물쇠Lock 처리
if (isDeveloper || widget.studentId != "2061")
_buildModernCard(
icon: widget.isDeviceMatched
? Icons.contactless_rounded
: Icons.lock_rounded,
title: 'NFC 태그',
subtitle: widget.isDeviceMatched
? '출석 및 폰 수거 완료'
: '⚠️ 본인 인증 기기 전용',
color: widget.isDeviceMatched
? Colors.blue
: Colors.grey[400]!,
onTap: widget.isDeviceMatched
? () => _openPocketCheckIn(context)
: () {
ScaffoldMessenger.of(
context,
).showSnackBar(
const SnackBar(
content: Text(
'🚨 대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.',
),
),
);
},
isActionButton: widget.isDeviceMatched,
isLoading: _controller.isLoading,
),
// 🆕 [추가 지점] 기기 일치 여부 상관없이 태블릿에서도 누구나 확인 가능한 학교 상황판 카드
_buildModernCard(
icon: Icons.fastfood_rounded,
title: '실시간 학교 상황',
subtitle: '급식실 줄 & 매점 재고 확인',
color: Colors.orange[700]!,
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('🍔 실시간 학교 상황 페이지로 이동합니다.'),
),
);
},
),
if (isDeveloper)
_buildModernCard(
icon: Icons.monitor_heart_rounded,
title: '실시간 현황',
subtitle: '교사용 수거 모니터링',
color: Colors.teal,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const TeacherAttendancePage(),
),
),
),
if (isDeveloper)
_buildModernCard(
icon: Icons.terminal_rounded,
title: '서버 DB 제어',
subtitle: '시스템 원격 초기화',
color: Colors.amber[800]!,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const AdminDashboard(),
),
),
),
if (isDeveloper)
_buildModernCard(
icon: Icons.add_moderator_rounded,
title: '학생 계정 관리',
subtitle: 'UUID 리셋 및 승인',
color: Colors.purple,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const TeacherStudentManagementPage(),
),
),
),
if (isDeveloper)
_buildModernCard(
icon: Icons.delete_sweep_rounded,
title: '계정 강제 삭제',
subtitle: '학생 및 교사 DB 삭제',
color: Colors.red[600]!,
onTap: () => _showDeleteUserDialog(context),
),
if (isDeveloper)
_buildModernCard(
icon: Icons.edit_note_rounded,
title: 'NFC 태그 쓰기',
subtitle: '주머니 스티커 초기 설정',
color: Colors.deepPurple,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const NfcTagWriterScreen(),
),
),
),
],
),
),
),
);
},
),
const SizedBox(height: 32),
],
),
),
);
},
);
}
// 5️⃣ [카드 디자인 위젯] - 기존 형태 완전 보존
Widget _buildModernCard({
required IconData icon,
required String title,
required String subtitle,
required Color color,
required VoidCallback onTap,
bool isActionButton = false,
bool isLoading = false,
}) {
return InkWell(
onTap: isLoading ? null : onTap,
borderRadius: BorderRadius.circular(24),
child: Ink(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Icon(icon, color: color, size: 28),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
if (isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else if (isActionButton)
Icon(
Icons.touch_app_rounded,
size: 16,
color: color.withValues(alpha: 0.5),
),
],
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
height: 1.2,
),
),
],
),
],
),
),
);
}
}
-415
View File
@@ -1,415 +0,0 @@
// 📅 백품타 출석 달력 화면 (UI 전용) - 개인 연속출석(스트릭) + 관리자 이벤트 달력.
// "실시간 출석 현황"과 같은 패밀리룩(제목 알약 + 필 통계 + 둥근 카드)을 따른다.
// 서버 통신/상태는 lib/function/study_calendar_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/study_calendar_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class StudyCalendarScreen extends StatefulWidget {
final String studentId;
final bool canManageEvents;
const StudyCalendarScreen({
super.key,
required this.studentId,
this.canManageEvents = false,
});
@override
State<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(
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: Colors.red),
),
),
),
if (event != null) const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () async {
if (titleController.text.trim().isEmpty) return;
final (success, message) = await _controller.saveEvent(
date: key,
title: titleController.text,
description: descController.text,
createdBy: widget.studentId,
);
if (sheetContext.mounted) {
Navigator.of(sheetContext).pop();
}
if (!mounted) return;
AppNotice.show(
context,
message,
icon: success
? Icons.check_circle_rounded
: Icons.error_outline_rounded,
);
},
child: const Text('저장'),
),
),
],
),
],
),
);
},
);
}
/// 넓은 화면(웹 데스크톱)에서 내용이 양옆으로 늘어지지 않게 가운데 최대 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: 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
: Colors.orange,
),
),
],
),
),
);
},
),
const SizedBox(height: 16),
Row(
children: [
_legendDot(AppPalette.ink, '공부한 날'),
const SizedBox(width: 16),
_legendIcon(
Icons.card_giftcard_rounded,
Colors.orange,
'이벤트',
),
],
),
],
),
);
},
);
}
Widget _statPill(String label, int count) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: AppPalette.sage),
),
child: Column(
children: [
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
const SizedBox(height: 4),
Text(
'$count일',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppPalette.ink,
),
),
],
),
);
}
Widget _legendDot(Color color, String label) {
return Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
);
}
Widget _legendIcon(IconData icon, Color color, String label) {
return Row(
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
);
}
}
-324
View File
@@ -1,324 +0,0 @@
// 🍅 뽀모도로 루틴 공유 화면 (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,
),
);
}
}
-224
View File
@@ -1,224 +0,0 @@
// 🏆 학년별 공부시간 랭킹 화면 (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;
if (h > 0) return '$h시간 $m분';
return '$m분';
}
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: 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,
),
),
);
}
}
-527
View File
@@ -1,527 +0,0 @@
// ⏱️ 우리 학교 전용 "백품타" 공부 타이머 화면 (UI 전용).
// 서버 통신/상태는 lib/function/study_timer_controller.dart가 담당한다.
import 'dart:async';
import 'package:flutter/material.dart';
import '../function/browser_notification.dart';
import '../function/study_timer_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'launchpad_transition.dart';
import 'study_preset_screen.dart';
import 'study_ranking_screen.dart';
import 'title_pill.dart';
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;
@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() {
WidgetsBinding.instance.removeObserver(this);
_awayTimer?.cancel();
_controller.dispose();
super.dispose();
}
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;
if (h > 0) return '$h시간 $m분';
return '$m분';
}
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: Colors.redAccent, 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('저장'),
),
],
),
);
},
);
},
);
}
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 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 : AppPalette.ink,
),
),
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: AppPalette.ink,
onTap: () {
requestNotificationPermission();
_runAction(_controller.start);
},
);
}
if (_controller.pomodoroEnabled && _controller.phase != null) {
// 뽀모도로 자동 전환 중에는 일시정지/재개는 자동으로 일어나니 종료 버튼만 보여준다.
return _circleButton(
size: 140,
icon: Icons.stop_rounded,
label: '공부 종료',
color: Colors.red[400]!,
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 ? AppPalette.ink : Colors.amber[700]!,
onTap: () =>
_runAction(isPaused ? _controller.resume : _controller.pause),
),
const SizedBox(width: 20),
_circleButton(
size: 110,
icon: Icons.stop_rounded,
label: '공부 종료',
color: Colors.red[400]!,
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: 100,
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage),
),
child: Column(
children: [
Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 12)),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
],
),
);
}
}
+228 -451
View File
@@ -2,10 +2,6 @@
// 서버 통신·상태·파생 로직은 lib/function/teacher_attendance_controller.dart가 담당한다.
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 카드 스타일 리스트화)
@@ -32,11 +28,13 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
super.dispose();
}
/// 컨트롤러 액션을 실행하고, 결과 메시지를 알약형 알림으로 보여준다.
/// 컨트롤러 액션을 실행하고, 결과 메시지를 스낵바로 보여준다.
Future<void> _runAction(Future<String> Function() action) async {
final message = await action();
if (!mounted) return;
AppNotice.show(context, message);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
Future<void> _showAttendanceTimeDialog() async {
@@ -64,7 +62,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('테스트용 허용시간 초기화'),
title: const Text('🧪 테스트용 허용시간 초기화'),
content: const Text(
'하교 처리나 반출 허용 시간 설정으로 켜져 있는 모든 허용 시간대를 지금 즉시 해제합니다.\n'
'(무단반출 감지 테스트할 때만 사용하세요)',
@@ -127,7 +125,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('전체 학생 반출 허용 시간 설정'),
title: const Text('⏰ 전체 학생 반출 허용 시간 설정'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -170,7 +168,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('하교 처리'),
title: const Text('🏫 하교 처리'),
content: const Text(
'지금부터 모든 학생의 반출이 자동으로 허용되며, 더 이상 무단반출 경고가 뜨지 않습니다.\n하교 처리하시겠습니까?',
),
@@ -204,21 +202,67 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
final int absentCount = totalCount - checkedInCount;
return Scaffold(
backgroundColor: AppPalette.mist,
// 🪶 검정 배너 대신, 제목 알약은 "전체 학생 수" 버튼 바로 위(사이드바/모바일 요약 위)에 둔다.
// 나머지 기능은 전부 "자습실 시간 설정 메뉴"(런치패드 스타일 오버레이) 하나로 모은다.
// 폰에서는 사이드바가 없으니 여기 아이콘으로도 같은 메뉴를 열 수 있게 둔다.
backgroundColor: Colors.grey[100],
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
title: const Text(
'📋 실시간 출석 현황',
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.more_horiz_rounded),
tooltip: '자습실 시간 설정 메뉴',
onPressed: _showTimeSettingsMenu,
onPressed: (_controller.isLoading || _controller.isRefreshing)
? null
: _controller.manualRefresh,
icon: _controller.isRefreshing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
const SizedBox(width: 4),
)
: 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),
],
),
body: _controller.isLoading
@@ -251,30 +295,9 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
final groups = _controller.studentsByGrade;
return Column(
children: [
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(),
),
_buildSummaryCards(total, checkedIn, absent),
_buildPermissionBanner(),
const SizedBox(height: 12),
_buildFilterChips(),
Expanded(
child: groups.isEmpty
? const Center(
@@ -313,7 +336,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
width: 4,
height: 16,
decoration: BoxDecoration(
color: AppPalette.ink,
color: Colors.blue,
borderRadius: BorderRadius.circular(2),
),
),
@@ -323,7 +346,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppPalette.ink,
color: Colors.black87,
),
),
const SizedBox(width: 6),
@@ -363,9 +386,9 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
? Colors.red
: (isPending ? Colors.orange : (isComplete ? Colors.blue : Colors.red));
final String statusLine = hasViolation
? '무단반출 (${student['violationTime']})'
? '🚨 무단반출 (${student['violationTime']})'
: (isPending
? '미완료 (${student['checkInTime']})'
? '⏳ 미완료 (${student['checkInTime']})'
: (isComplete
? '${student['checkInTime']}'
: (student['hasEverCheckedIn'] == true ? '미제출' : '미등록')));
@@ -373,12 +396,11 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: hasViolation ? Colors.red[50] : AppPalette.paper,
color: hasViolation ? Colors.red[50] : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: hasViolation ? Colors.red[300]! : AppPalette.sage,
width: hasViolation ? 1.5 : 1,
),
border: hasViolation
? Border.all(color: Colors.red[300]!, width: 1.5)
: null,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
@@ -418,15 +440,15 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
vertical: 3,
),
decoration: BoxDecoration(
color: AppPalette.linen,
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppPalette.sage),
border: Border.all(color: Colors.blue.shade200),
),
child: Text(
student['pocketNumber'],
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppPalette.ink,
color: Colors.blueAccent,
fontSize: 11,
),
),
@@ -485,24 +507,51 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
// -----------------------------------------------------------------------
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
// 왼쪽에 요약/필터 사이드바, 오른쪽에 좌석표처럼 촘촘한 학생 칸 그리드를 둬서
// 80명이 넘는 인원도 스크롤을 최소화하고 한눈에 볼 수 있게 한다.
// -----------------------------------------------------------------------
Widget _buildDesktopBody(int total, int checkedIn, int absent) {
final students = _controller.filteredStudents;
return Padding(
final groups = _controller.studentsByGrade;
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildPermissionBanner(),
const SizedBox(height: 12),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
Row(
children: [
_buildDesktopSidebar(total, checkedIn, absent),
const SizedBox(width: 20),
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: Container(
width: double.infinity,
@@ -517,324 +566,105 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
),
],
),
child: _buildDenseSeatGrid(students),
child: groups.isEmpty
? const Center(
child: Text(
'해당하는 학생이 없습니다.',
style: TextStyle(color: Colors.grey),
),
)
: _buildGradeGroupedList(
groups,
padding: const EdgeInsets.all(20),
),
),
),
],
),
),
],
),
);
}
// 🧾 왼쪽 요약/필터 사이드바. 인원 수 캡슐을 누르면 그 필터가 바로 적용된다.
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),
Widget _statTile(String label, String value, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: AppPalette.sage),
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
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 _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),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: selected ? AppPalette.ink : AppPalette.paper,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: selected ? AppPalette.ink : AppPalette.sage,
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 26),
),
child: Column(
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(color: Colors.grey[600], fontSize: 13),
),
const SizedBox(height: 2),
Text(
value,
style: TextStyle(
fontSize: 12,
color: selected ? Colors.white70 : Colors.grey[600],
color: color,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
);
}
/// 📊 상단 요약 카드 뷰 (전체 / 출석 완료 / 미출석)
Widget _buildSummaryCards(int total, int checkedIn, int absent) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
color: const Color(0xFF1E293B),
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(
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
? Colors.red[50]!
: isComplete
? Colors.blue[50]!
: isPending
? Colors.orange[50]!
: AppPalette.paper;
final Color border = hasViolation
? Colors.red[300]!
: isComplete
? Colors.blue[200]!
: isPending
? Colors.orange[200]!
: 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: Colors.red[700],
color: color,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
@@ -859,7 +689,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
const SizedBox(width: 8),
Expanded(
child: Text(
'지금 전체 반출 허용 중입니다 ($until 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.',
'🔓 지금 전체 반출 허용 중입니다 ($until 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.',
style: TextStyle(
color: Colors.amber[900],
fontWeight: FontWeight.bold,
@@ -871,116 +701,63 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
),
);
}
}
// 🚀 "자습실 시간 설정 메뉴" 카드 — 메인 대시보드 로그아웃/설정 메뉴와 같은 톤으로 통일.
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),
/// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자)
Widget _buildFilterChips() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_TimeMenuRow(
icon: isRefreshing ? null : Icons.refresh_rounded,
label: '새로고침',
onTap: () => onPick('refresh'),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilterChip(
label: const Text("전체"),
selected: _controller.filterType == "ALL",
onSelected: (_) => _controller.setFilter("ALL"),
),
const 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 == "CHECKED_IN",
onSelected: (_) => _controller.setFilter("CHECKED_IN"),
),
const Divider(height: 1, color: AppPalette.sage),
_TimeMenuRow(
icon: Icons.timer_outlined,
label: '반출 허용 시간 설정',
onTap: () => onPick('permission_window'),
),
const Divider(height: 1, color: AppPalette.sage),
_TimeMenuRow(
icon: Icons.school_rounded,
label: '하교 처리',
onTap: () => onPick('dismissal'),
),
const Divider(height: 1, color: AppPalette.sage),
_TimeMenuRow(
icon: Icons.bug_report_outlined,
label: '테스트용: 허용시간 초기화',
dim: true,
onTap: () => onPick('test_reset'),
FilterChip(
label: const Text("🔴 미출석자"),
selected: _controller.filterType == "ABSENT",
onSelected: (_) => _controller.setFilter("ABSENT"),
),
],
),
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"),
),
);
}
}
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("2학년"),
selected: _controller.gradeFilter == "2",
onSelected: (_) => _controller.setGradeFilter("2"),
),
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,
),
),
),
],
),
],
),
);
}
-521
View File
@@ -1,521 +0,0 @@
// 📍 선생님 호출 화면(학생용) (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 ? Colors.orange : 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)
: Colors.orange[50],
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected ? Colors.white54 : Colors.orange,
),
),
child: Text(
'수업중',
style: TextStyle(
fontSize: 9,
color: isSelected ? Colors.white : Colors.orange,
),
),
),
],
),
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),
),
],
),
),
],
);
}
}
+273
View File
@@ -0,0 +1,273 @@
// 👨‍🏫 교사 대시보드. 실시간 출석 확인, 학생 계정 관리 화면으로 가는 메뉴만 담당한다.
import 'package:flutter/material.dart';
import 'login_screen.dart';
import 'teacher_attendance_page.dart';
import 'teacher_student_management_page.dart';
// ==========================================
// 👨‍🏫 4. 선생님 대시보드 (기존 3초 타이머 완벽 유지)
// ==========================================
class TeacherDashboard extends StatefulWidget {
final String? teacherId;
final String? teacherName;
const TeacherDashboard({super.key, this.teacherId, this.teacherName});
@override
State<TeacherDashboard> createState() => _TeacherDashboardState();
}
class _TeacherDashboardState extends State<TeacherDashboard> {
// 🧼 [수정] 사용하지 않던 _isLoading 변수를 삭제하여 경고를 완벽히 해결했습니다!
@override
Widget build(BuildContext context) {
// 다른 화면에서 null이 넘어왔을 때를 대비한 안전망 방탄 코드
final String displayName = widget.teacherName ?? "간편인증 선생";
final String displayId = widget.teacherId ?? "간편인증";
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: const Text(
'👨‍🏫 TEACHER PORTAL',
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2),
),
centerTitle: true,
backgroundColor: Colors.green[700], // 교사 전용 그린 테마 컬러
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.logout_rounded),
onPressed: () => Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
),
),
],
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 💳 [상단 배너 가이드] StudentDashboard와 100% 일치하는 프로필 카드 레이아웃
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
decoration: BoxDecoration(
color: Colors.green[700],
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(32),
bottomRight: Radius.circular(32),
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: CircleAvatar(
radius: 30,
backgroundColor: Colors.green[50],
child: Icon(
Icons.admin_panel_settings_rounded,
size: 32,
color: Colors.green[700],
),
),
),
const SizedBox(width: 18),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$displayName 님',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'교직원 번호: $displayId | 교사 권한 활성화됨',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
),
),
],
),
],
),
),
const SizedBox(height: 32),
// 🏷️ [세로 바 타이틀 인디케이터] 구조 일치화
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Row(
children: [
Container(
width: 4,
height: 18,
decoration: BoxDecoration(
color: Colors.green[700],
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 10),
const Text(
'스마트 교사용 관리 메뉴',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
],
),
),
const SizedBox(height: 16),
// 📊 [그리드 레이아웃 메뉴] 시후의 카드 컴포넌트 스타일 적용
// 🖥️ 데스크톱 브라우저에서 카드가 지나치게 커지지 않도록 최대 너비를 제한한다.
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 700),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.95,
children: [
_buildModernCard(
icon: Icons.assignment_turned_in_rounded,
title: '실시간 출석 확인',
subtitle: '학생 제출 로그 모니터링',
color: Colors.blue,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TeacherAttendancePage(),
),
),
),
_buildModernCard(
icon: Icons.manage_accounts_rounded,
title: '학생 계정 관리',
subtitle: '계정 추가 및 강제 리셋',
color: Colors.orange,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const TeacherStudentManagementPage(),
),
),
),
],
),
),
),
),
const SizedBox(height: 32),
],
),
),
);
}
// 💎 [시후 대시보드 전용 카드 위젯 이식 완료]
Widget _buildModernCard({
required IconData icon,
required String title,
required String subtitle,
required Color color,
required VoidCallback onTap,
bool isActionButton = false,
bool isLoading = false,
}) {
return InkWell(
onTap: isLoading ? null : onTap,
borderRadius: BorderRadius.circular(24),
child: Ink(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Icon(icon, color: color, size: 28),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
),
if (isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else if (isActionButton)
Icon(
Icons.touch_app_rounded,
size: 16,
color: color.withValues(alpha: 0.5),
),
],
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
height: 1.2,
),
),
],
),
],
),
),
);
}
}
-291
View File
@@ -1,291 +0,0 @@
// 📍 선생님 위치 등록 화면(교사용) (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),
),
],
),
),
],
);
}
}
+13 -15
View File
@@ -2,9 +2,6 @@
// lib/function/teacher_register_controller.dart가 담당한다.
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});
@@ -37,7 +34,9 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
String secret = _secretController.text.trim();
if (id.isEmpty || pw.isEmpty || name.isEmpty || secret.isEmpty) {
AppNotice.show(context, '모든 빈칸을 입력해 주세요.');
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('⚠️ 모든 빈칸을 입력해 주세요.')));
return;
}
@@ -48,7 +47,9 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
secretCode: secret,
);
if (!mounted) return;
AppNotice.show(context, message);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
if (success) {
Navigator.pop(context); // 가입 성공 시 로그인 화면으로 복귀
}
@@ -60,13 +61,10 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
listenable: _controller,
builder: (context, _) {
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill('교사 회원가입'),
title: const Text('교사 회원가입'),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
@@ -74,7 +72,7 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'교직원 전용 인증',
'👨‍🏫 교직원 전용 인증',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
@@ -87,7 +85,7 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
controller: _secretController,
obscureText: true,
decoration: const InputDecoration(
labelText: '교사 인증 비밀코드 입력',
labelText: '🔑 교사 인증 비밀코드 입력',
border: OutlineInputBorder(),
),
),
@@ -124,8 +122,8 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
height: 50,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
),
onPressed: _controller.isLoading ? null : _registerTeacher,
child: _controller.isLoading
+249 -312
View File
@@ -5,13 +5,6 @@ import 'package:desktop_drop/desktop_drop.dart';
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;
const double kAttendanceTileHeight = 148;
// -----------------------------------------------------------------------------
// 👥 [서브 화면 2] 학생 계정 관리 란 (추가 양식 폼 + 강제 삭제 다이얼로그 완전 내장)
@@ -30,6 +23,7 @@ class _TeacherStudentManagementPageState
TeacherStudentManagementController();
final TextEditingController _addIdController = TextEditingController();
final TextEditingController _addNameController = TextEditingController();
final TextEditingController _addPwController = TextEditingController();
int? _selectedGrade;
bool _isDragging = false;
@@ -44,32 +38,39 @@ class _TeacherStudentManagementPageState
_controller.dispose();
_addIdController.dispose();
_addNameController.dispose();
_addPwController.dispose();
super.dispose();
}
// ➕ [학생 추가 버튼 동작] 기본 비밀번호는 항상 1234로 고정.
// ➕ [학생 추가 버튼 동작]
Future<void> _addStudentAccount() async {
final sId = _addIdController.text.trim();
final sName = _addNameController.text.trim();
final sPw = _addPwController.text.trim();
if (sId.isEmpty || sName.isEmpty) {
AppNotice.show(context, '모든 입력란을 채워주세요.');
if (sId.isEmpty || sName.isEmpty || sPw.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('⚠️ 모든 입력란을 채워주세요.')));
return;
}
final (success, message) = await _controller.addStudentAccount(
studentId: sId,
name: sName,
password: '1234',
password: sPw,
grade: _selectedGrade,
);
if (!mounted) return;
if (success) {
_addIdController.clear();
_addNameController.clear();
_addPwController.clear();
setState(() => _selectedGrade = null);
}
AppNotice.show(context, message);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// 📄 [파일 선택 버튼 동작] "파일 선택"으로 엑셀 고르기
@@ -87,12 +88,18 @@ class _TeacherStudentManagementPageState
rows = _controller.parseExcelBytes(bytes);
} catch (e) {
if (!mounted) return;
AppNotice.show(context, '엑셀 파일을 읽는 데 실패했습니다: $e');
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('❌ 엑셀 파일을 읽는 데 실패했습니다: $e')));
return;
}
if (rows.isEmpty) {
if (!mounted) return;
AppNotice.show(context, '유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('⚠️ 유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)'),
),
);
return;
}
_showBulkPreviewDialog(rows);
@@ -104,7 +111,7 @@ class _TeacherStudentManagementPageState
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Text('${rows.length}명 확인됨'),
title: Text('📄 ${rows.length}명 확인됨'),
content: SizedBox(
width: 400,
child: Column(
@@ -155,8 +162,8 @@ class _TeacherStudentManagementPageState
_runBulkImport(rows);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -201,7 +208,9 @@ class _TeacherStudentManagementPageState
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Text(result.failures.isEmpty ? '일괄 등록 완료' : '일괄 등록 완료 (일부 실패)'),
title: Text(
result.failures.isEmpty ? '✅ 일괄 등록 완료' : '⚠️ 일괄 등록 완료 (일부 실패)',
),
content: SizedBox(
width: 400,
child: Column(
@@ -274,7 +283,9 @@ class _TeacherStudentManagementPageState
grade: selected,
);
if (!mounted) return;
AppNotice.show(context, message);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// 🔓 [목록 행의 "기기 리셋" 버튼] 학생이 폰을 바꿨을 때 등록 초기화.
@@ -283,8 +294,8 @@ class _TeacherStudentManagementPageState
context: context,
builder: (ctx) => AlertDialog(
title: const Text(
'기기 등록 초기화',
style: TextStyle(fontWeight: FontWeight.bold, color: AppPalette.ink),
'⚠️ 기기 등록 초기화',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.purple),
),
content: Text('$studentName 학생의 스마트폰 기기 등록과 비밀번호(1234)를 초기화하시겠습니까?'),
actions: [
@@ -294,8 +305,8 @@ class _TeacherStudentManagementPageState
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
backgroundColor: Colors.purple,
foregroundColor: Colors.white,
),
onPressed: () async {
Navigator.pop(ctx);
@@ -304,7 +315,9 @@ class _TeacherStudentManagementPageState
studentName,
);
if (!mounted) return;
AppNotice.show(context, message);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
},
child: const Text('초기화 승인'),
),
@@ -348,7 +361,9 @@ class _TeacherStudentManagementPageState
studentId,
);
if (!mounted) return;
AppNotice.show(context, message);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
},
child: const Text('영구 삭제'),
),
@@ -357,37 +372,56 @@ class _TeacherStudentManagementPageState
);
}
// 🏷️ 섹션 제목 (색 인디케이터 바 + 텍스트).
Widget _sectionTitle(String text, {Color color = AppPalette.ink}) {
return Row(
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: const Text(
'⚙️ 학생 통합 관리 센터',
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
elevation: 0,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🏷️ 인디케이터 바 1 (추가 메뉴)
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: color,
color: Colors.orange,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
text,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
const Text(
'신규 학생 계정 추가',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
);
}
),
const SizedBox(height: 16),
// 📝 [정사각형 박스 1] 신규 학생 계정 추가 폼. 박스가 작아도 버튼까지 다 보이도록 촘촘하게 배치.
Widget _buildAddAccountBox() {
const InputDecoration Function(String, IconData) fieldDecoration =
_compactFieldDecoration;
return Container(
padding: const EdgeInsets.all(10),
// 📝 학생 추가 컨테이너 폼
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppPalette.paper,
color: Colors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppPalette.sage),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
@@ -395,76 +429,72 @@ class _TeacherStudentManagementPageState
),
],
),
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _addIdController,
keyboardType: TextInputType.number,
style: const TextStyle(fontSize: 12),
decoration: fieldDecoration('학번', Icons.badge),
decoration: const InputDecoration(
labelText: '학번',
prefixIcon: Icon(Icons.badge),
),
const SizedBox(height: 4),
),
const SizedBox(height: 12),
TextField(
controller: _addNameController,
style: const TextStyle(fontSize: 12),
decoration: fieldDecoration('이름', Icons.person),
),
const SizedBox(height: 4),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Row(
children: [
Icon(Icons.lock, size: 14, color: Colors.grey),
SizedBox(width: 6),
Text(
'초기 비밀번호: 1234 (고정)',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
],
decoration: const InputDecoration(
labelText: '이름',
prefixIcon: Icon(Icons.person),
),
),
const SizedBox(height: 4),
const SizedBox(height: 12),
TextField(
controller: _addPwController,
obscureText: true,
decoration: const InputDecoration(
labelText: '초기 비밀번호',
prefixIcon: Icon(Icons.lock),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
initialValue: _selectedGrade,
style: const TextStyle(fontSize: 12, color: Colors.black87),
decoration: fieldDecoration('학년 (선택)', Icons.class_),
decoration: const InputDecoration(
labelText: '학년 (선택)',
prefixIcon: Icon(Icons.class_),
),
items: const [
DropdownMenuItem(value: 1, child: Text('1학년')),
DropdownMenuItem(value: 2, child: Text('2학년')),
DropdownMenuItem(value: 3, child: Text('3학년')),
],
onChanged: (value) => setState(() => _selectedGrade = value),
onChanged: (value) =>
setState(() => _selectedGrade = value),
),
const SizedBox(height: 6),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 30,
height: 50,
child: ElevatedButton(
onPressed: _controller.isWorking ? null : _addStudentAccount,
onPressed: _controller.isWorking
? null
: _addStudentAccount,
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
padding: EdgeInsets.zero,
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(12),
),
),
child: _controller.isWorking
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
? const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text(
'학생 등록 완료',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
fontSize: 15,
),
),
),
@@ -472,22 +502,32 @@ class _TeacherStudentManagementPageState
],
),
),
);
}
const SizedBox(height: 36),
static InputDecoration _compactFieldDecoration(String label, IconData icon) {
return InputDecoration(
labelText: label,
labelStyle: const TextStyle(fontSize: 12),
prefixIcon: Icon(icon, size: 16),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
);
}
// 🏷️ 인디케이터 바 (엑셀 일괄 등록)
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'엑셀로 한번에 추가 (신입생 등)',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
// 📤 [정사각형 박스 2] 엑셀 일괄 등록 드롭존.
Widget _buildExcelBox() {
return DropTarget(
DropTarget(
onDragEntered: (_) => setState(() => _isDragging = true),
onDragExited: (_) => setState(() => _isDragging = false),
onDragDone: (details) async {
@@ -502,238 +542,71 @@ class _TeacherStudentManagementPageState
borderRadius: BorderRadius.circular(24),
child: Container(
width: double.infinity,
height: double.infinity,
padding: const EdgeInsets.all(20),
padding: const EdgeInsets.symmetric(vertical: 28),
decoration: BoxDecoration(
color: _isDragging ? Colors.orange[50] : AppPalette.paper,
color: _isDragging ? Colors.orange[50] : Colors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: _isDragging ? Colors.orange : AppPalette.sage,
color: _isDragging
? Colors.orange
: Colors.grey.shade300,
width: _isDragging ? 2 : 1,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.upload_file_rounded,
size: 40,
color: _isDragging ? Colors.orange : AppPalette.sage,
color: _isDragging
? Colors.orange
: Colors.grey[400],
),
const SizedBox(height: 12),
Text(
_isDragging ? '여기에 놓으세요' : '엑셀 파일을 드래그하거나 눌러서 선택',
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
color: _isDragging ? Colors.orange[800] : AppPalette.ink,
color: _isDragging
? Colors.orange[800]
: Colors.black87,
),
),
const SizedBox(height: 4),
Text(
'.xlsx · 1행은 머리글, A열=학번 B열=이름 C열=학년(선택)',
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
// 🧑‍🎓 [학생 목록 타일] 아이콘/이름/상태/액션을 모두 중앙 정렬한 정사각 타일.
Widget _buildStudentTile(Map<String, dynamic> student) {
final String studentId = student['id'].toString();
final String studentName = student['name'].toString();
final int? grade = student['grade'] as int?;
final bool needsReset = student['device'] == '초기화 필요';
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(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: needsReset
? Colors.red.withValues(alpha: 0.1)
: AppPalette.linen,
shape: BoxShape.circle,
),
child: Icon(
needsReset ? Icons.lock_reset : Icons.person,
size: 18,
color: needsReset ? Colors.red : AppPalette.ink,
),
),
const SizedBox(height: 8),
Text(
'$studentName ($studentId)',
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 4),
Text(
needsReset ? '초기화 대기중' : '정상 등록',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11.5,
color: needsReset ? Colors.red[700] : Colors.grey[600],
fontWeight: needsReset ? FontWeight.bold : FontWeight.normal,
fontSize: 12,
color: Colors.grey[500],
),
),
const SizedBox(height: 6),
Wrap(
alignment: WrapAlignment.center,
spacing: 2,
runSpacing: 2,
children: [
Builder(
builder: (chipContext) => ActionChip(
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
labelPadding: const EdgeInsets.symmetric(horizontal: 6),
label: Text(
grade != null ? '$grade학년' : '미배정',
style: const TextStyle(fontSize: 11),
),
onPressed: () => _showGradeMenu(
chipContext,
studentId,
studentName,
grade,
),
),
),
IconButton(
icon: const Icon(
Icons.lock_reset,
color: AppPalette.ink,
size: 18,
),
tooltip: '기기 리셋',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
onPressed: () => _confirmResetDevice(studentId, studentName),
),
IconButton(
icon: const Icon(
Icons.delete_forever_rounded,
color: Colors.redAccent,
size: 18,
),
tooltip: '계정 영구 삭제',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
onPressed: () => _confirmDeleteStudent(studentId, studentName),
textAlign: TextAlign.center,
),
],
),
],
),
);
}
@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: const TitlePill('학생 통합 관리 센터'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: LayoutBuilder(
builder: (context, constraints) {
// 🖥️ 웹(넓은 화면)은 계정 추가/엑셀 등록을 정사각형 2칸으로 나란히, 폰은 기존처럼 세로로 쌓는다.
// 📐 학생 목록 타일은 "실시간 출석 확인" 목록의 타일(220x148)과 동일하게 맞춘다.
final bool isWide = constraints.maxWidth >= 800;
final double listWidth = constraints.maxWidth;
// 📉 계정 추가 / 엑셀 등록 칸: 정사각형 기본 크기의 25%(+ 버튼까지 다 보이도록 확대).
final double formSquareSize =
((constraints.maxWidth - 20) / 2) * 0.25 * 1.45;
final Widget addBoxColumn = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('신규 학생 계정 추가'),
const SizedBox(height: 16),
SizedBox(
width: isWide ? formSquareSize : double.infinity,
height: isWide ? formSquareSize : null,
child: _buildAddAccountBox(),
),
],
);
final Widget excelBoxColumn = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('엑셀로 한번에 추가 (신입생 등)'),
const SizedBox(height: 16),
SizedBox(
width: isWide ? formSquareSize : double.infinity,
height: isWide ? formSquareSize : 240,
child: _buildExcelBox(),
),
],
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
isWide
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
addBoxColumn,
const SizedBox(width: 20),
excelBoxColumn,
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
addBoxColumn,
const SizedBox(height: 24),
excelBoxColumn,
],
),
const SizedBox(height: 36),
// 🏷️ 인디케이터 바 (전체 학생 목록) — 위 두 칸을 합친 폭에 맞춘다.
SizedBox(
width: listWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🏷️ 인디케이터 바 (전체 학생 목록)
Row(
children: [
_sectionTitle('전체 학생 목록'),
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'전체 학생 목록',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh_rounded),
@@ -747,18 +620,14 @@ class _TeacherStudentManagementPageState
const SizedBox(height: 8),
const Text(
'학년 칩을 눌러 학년을 바꾸고, 기기 리셋은 학생이 폰을 바꿨을 때 사용하세요.',
style: TextStyle(
color: Colors.black54,
fontSize: 12,
),
style: TextStyle(color: Colors.black54, fontSize: 12),
),
const SizedBox(height: 12),
_controller.isLoadingStudents
? const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(
child: CircularProgressIndicator(),
),
child: Center(child: CircularProgressIndicator()),
)
: _controller.students.isEmpty
? const Padding(
@@ -770,30 +639,98 @@ class _TeacherStudentManagementPageState
),
),
)
: GridView.builder(
: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent:
kAttendanceTileWidth,
mainAxisExtent: kAttendanceTileHeight,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _controller.students.length,
itemBuilder: (context, index) =>
_buildStudentTile(
_controller.students[index],
itemBuilder: (context, index) {
final student = _controller.students[index];
final String studentId = student['id'].toString();
final String studentName = student['name'].toString();
final int? grade = student['grade'] as int?;
final bool needsReset = student['device'] == '초기화 필요';
return Card(
elevation: 1,
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
leading: CircleAvatar(
backgroundColor: needsReset
? Colors.red[50]
: Colors.blue[50],
child: Icon(
needsReset ? Icons.lock_reset : Icons.person,
color: needsReset ? Colors.red : Colors.blue,
),
),
title: Text(
'$studentName ($studentId)',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
needsReset ? '기기 초기화 승인 대기중' : '정상 등록 상태',
style: TextStyle(
color: needsReset ? Colors.red : Colors.grey,
fontWeight: needsReset
? FontWeight.bold
: FontWeight.normal,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Builder(
builder: (chipContext) => ActionChip(
label: Text(
grade != null ? '$grade학년' : '미배정',
),
onPressed: () => _showGradeMenu(
chipContext,
studentId,
studentName,
grade,
),
),
),
IconButton(
icon: const Icon(
Icons.lock_reset,
color: Colors.purple,
),
tooltip: '기기 리셋',
onPressed: () => _confirmResetDevice(
studentId,
studentName,
),
),
IconButton(
icon: const Icon(
Icons.delete_forever_rounded,
color: Colors.redAccent,
),
tooltip: '계정 영구 삭제',
onPressed: () => _confirmDeleteStudent(
studentId,
studentName,
),
),
],
),
),
],
);
},
),
],
),
),
);
},
-32
View File
@@ -1,32 +0,0 @@
// 🪶 검정 AppBar 대신 쓰는 둥근 제목 알약. 모든 화면이 똑같은 폰트/크기를 쓰고,
// 화면 맨 위에 딱 붙어 보이지 않도록 위쪽에 살짝 여백을 둔다.
import 'package:flutter/material.dart';
import '../theme/app_palette.dart';
class TitlePill extends StatelessWidget {
final String text;
const TitlePill(this.text, {super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: AppPalette.ink,
borderRadius: BorderRadius.circular(999),
),
child: Text(
text,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
);
}
}
@@ -11,7 +11,6 @@ import file_picker_darwin
import firebase_core
import firebase_messaging
import flutter_local_notifications
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
@@ -20,5 +19,4 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}
+1 -89
View File
@@ -350,14 +350,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.1"
flutter_staggered_grid_view:
dependency: "direct main"
description:
name: flutter_staggered_grid_view
sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -480,30 +472,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
@@ -576,62 +544,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
url: "https://pub.dev"
source: hosted
version: "2.4.28"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
url: "https://pub.dev"
source: hosted
version: "2.5.7"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
@@ -791,4 +703,4 @@ packages:
version: "6.6.1"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.44.0"
flutter: ">=3.41.0"
+1 -3
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.3.0+7
version: 1.1.5+7
environment:
sdk: ^3.12.2
@@ -41,7 +41,6 @@ dependencies:
nfc_manager: ^4.2.1
nfc_manager_ndef: ^1.1.0
http: ^1.1.0
shared_preferences: ^2.3.3
# 🔦 주머니 제출 모드 무단 반출 감지용 조도 센서 (Android 전용, iOS는 SensorKit 엔타이틀먼트 필요)
light: ^5.0.0
@@ -57,7 +56,6 @@ dependencies:
file_picker: ^12.1.2
desktop_drop: ^0.8.3
web: ^1.1.1
flutter_staggered_grid_view: ^0.7.0
dev_dependencies:
flutter_test:
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 20 KiB

-20
View File
@@ -19,10 +19,6 @@
<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">
@@ -35,22 +31,6 @@
<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
View File
@@ -3,8 +3,8 @@
"short_name": "school_attendance",
"start_url": ".",
"display": "standalone",
"background_color": "#EEF0F2",
"theme_color": "#1C1C1C",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,