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) { Widget build(BuildContext context) {
return const MaterialApp( return const MaterialApp(
debugShowCheckedModeBanner: false, 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 { try {
final response = await http.get(url); final response = await http.get(url);
if (response.statusCode == 200) { if (response.statusCode == 200) {
return 'DB가 깔끔하게 초기화되었습니다! (출석 번호 1번부터 시작)'; return '💥 DB가 깔끔하게 초기화되었습니다! (출석 번호 1번부터 시작)';
} else { } else {
return '초기화 실패: 서버 권한 오류 (${response.statusCode})'; return '❌ 초기화 실패: 서버 권한 오류 (${response.statusCode})';
} }
} catch (e) { } catch (e) {
return '네트워크 에러: 서버와 연결할 수 없습니다.'; return '❌ 네트워크 에러: 서버와 연결할 수 없습니다.';
} finally { } finally {
_isLoading = false; _isLoading = false;
notifyListeners(); 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 String? name;
final bool isDeviceMatched; final bool isDeviceMatched;
final String? errorMessage; final String? errorMessage;
final int? grade; // 🎓 공부 타이머 학년별 랭킹에서 본인 학년을 기본값으로 쓰기 위함.
const LoginResult.masterSuccess({required this.studentId, required this.name}) const LoginResult.masterSuccess({
: outcome = LoginOutcome.masterSuccess, required this.studentId,
required this.name,
}) : outcome = LoginOutcome.masterSuccess,
role = null, role = null,
isDeviceMatched = true, isDeviceMatched = true,
errorMessage = null, errorMessage = null;
grade = null;
const LoginResult.needsPasswordChange({ const LoginResult.needsPasswordChange({
required this.studentId, required this.studentId,
required this.name, required this.name,
required this.role, required this.role,
required this.isDeviceMatched, required this.isDeviceMatched,
this.grade,
}) : outcome = LoginOutcome.needsPasswordChange, }) : outcome = LoginOutcome.needsPasswordChange,
errorMessage = null; errorMessage = null;
@@ -39,7 +38,6 @@ class LoginResult {
required this.studentId, required this.studentId,
required this.name, required this.name,
required this.isDeviceMatched, required this.isDeviceMatched,
this.grade,
}) : outcome = LoginOutcome.success, }) : outcome = LoginOutcome.success,
errorMessage = null; errorMessage = null;
@@ -48,8 +46,7 @@ class LoginResult {
role = null, role = null,
studentId = null, studentId = null,
name = null, name = null,
isDeviceMatched = false, isDeviceMatched = false;
grade = null;
} }
class LoginController extends ChangeNotifier { class LoginController extends ChangeNotifier {
@@ -58,13 +55,16 @@ class LoginController extends ChangeNotifier {
Future<LoginResult> login(String id, String password) async { Future<LoginResult> login(String id, String password) async {
if (id.isEmpty || password.isEmpty) { if (id.isEmpty || password.isEmpty) {
return const LoginResult.error('학번과 비밀번호를 모두 입력해 주세요.'); return const LoginResult.error('⚠️ 학번과 비밀번호를 모두 입력해 주세요.');
} }
// 🔥 [개발자 마스터 계정] // 🔥 [개발자 마스터 계정]
if (id == "2061") { if (id == "2061") {
if (password == "happy9642!") { if (password == "happy9642!") {
return const LoginResult.masterSuccess(studentId: "2061", name: "훗춧가룻"); return const LoginResult.masterSuccess(
studentId: "2061",
name: "훗춧가룻",
);
} else { } else {
return const LoginResult.error('개발자 계정의 마스터 비밀번호가 올바르지 않습니다.'); return const LoginResult.error('개발자 계정의 마스터 비밀번호가 올바르지 않습니다.');
} }
@@ -106,7 +106,6 @@ class LoginController extends ChangeNotifier {
String name = ''; String name = '';
String studentId = ''; String studentId = '';
int isFirstLogin = 0; int isFirstLogin = 0;
int? grade;
var userObj = resData['user']; var userObj = resData['user'];
if (userObj != null) { if (userObj != null) {
@@ -120,7 +119,6 @@ class LoginController extends ChangeNotifier {
userObj['studentId']?.toString() ?? userObj['studentId']?.toString() ??
userObj['student_id']?.toString() ?? userObj['student_id']?.toString() ??
id; id;
grade = int.tryParse(userObj['grade']?.toString() ?? '');
var rawFirst = userObj['isFirstLogin'] ?? userObj['is_first_login']; var rawFirst = userObj['isFirstLogin'] ?? userObj['is_first_login'];
isFirstLogin = (rawFirst is bool) isFirstLogin = (rawFirst is bool)
@@ -133,7 +131,6 @@ class LoginController extends ChangeNotifier {
resData['studentName']?.toString() ?? resData['studentName']?.toString() ??
''; '';
studentId = resData['studentId']?.toString() ?? id; studentId = resData['studentId']?.toString() ?? id;
grade = int.tryParse(resData['grade']?.toString() ?? '');
var rawFirst = resData['isFirstLogin'] ?? resData['is_first_login']; var rawFirst = resData['isFirstLogin'] ?? resData['is_first_login'];
isFirstLogin = (rawFirst is bool) isFirstLogin = (rawFirst is bool)
@@ -161,7 +158,6 @@ class LoginController extends ChangeNotifier {
name: name, name: name,
role: role, role: role,
isDeviceMatched: isDeviceMatched, isDeviceMatched: isDeviceMatched,
grade: grade,
); );
} }
@@ -170,7 +166,6 @@ class LoginController extends ChangeNotifier {
studentId: studentId, studentId: studentId,
name: name, name: name,
isDeviceMatched: isDeviceMatched, isDeviceMatched: isDeviceMatched,
grade: grade,
); );
} else { } else {
String errorMsg = '로그인에 실패했습니다.'; String errorMsg = '로그인에 실패했습니다.';
@@ -205,9 +200,9 @@ class LoginController extends ChangeNotifier {
final resData = jsonDecode(utf8.decode(response.bodyBytes)); final resData = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && resData['status'] == 'success') { if (response.statusCode == 200 && resData['status'] == 'success') {
return (true, '비밀번호가 변경되었습니다!'); return (true, '✅ 비밀번호가 변경되었습니다!');
} else { } else {
return (false, '실패: ${resData['message']}'); return (false, '❌ 실패: ${resData['message']}');
} }
} catch (e) { } catch (e) {
return (false, '서버 에러 발생: $e'); return (false, '서버 에러 발생: $e');
+21 -25
View File
@@ -17,12 +17,8 @@ import '../config.dart' show baseUrl;
// 🛡️ iOS 근접센서 감시용 네이티브 채널 (ios/Runner/ProximityMonitorPlugin.swift 참고). // 🛡️ iOS 근접센서 감시용 네이티브 채널 (ios/Runner/ProximityMonitorPlugin.swift 참고).
// 조도 센서 API가 없는 iOS에서, 통화 중 화면이 꺼지는 것과 같은 원리인 // 조도 센서 API가 없는 iOS에서, 통화 중 화면이 꺼지는 것과 같은 원리인
// 근접센서로 "주머니 안/밖" 상태 변화를 감지해 대체한다. // 근접센서로 "주머니 안/밖" 상태 변화를 감지해 대체한다.
const MethodChannel _proximityMethodChannel = MethodChannel( const MethodChannel _proximityMethodChannel = MethodChannel('proximity_monitor');
'proximity_monitor', const EventChannel _proximityEventChannel = EventChannel('proximity_monitor/events');
);
const EventChannel _proximityEventChannel = EventChannel(
'proximity_monitor/events',
);
enum WatchMessageLevel { info, success, warning, error } enum WatchMessageLevel { info, success, warning, error }
@@ -82,9 +78,9 @@ class NfcPocketCheckinController extends ChangeNotifier {
// 화면이 열려있는 동안은 실시간으로 위반 알림을 받아 팝업을 띄운다. // 화면이 열려있는 동안은 실시간으로 위반 알림을 받아 팝업을 띄운다.
// 무단 반출이 한 번 감지되면 "신뢰된 감시 세션"은 끝난 것으로 보고, // 무단 반출이 한 번 감지되면 "신뢰된 감시 세션"은 끝난 것으로 보고,
// 다음 태깅은 체크아웃이 아니라 새 출석(재출석)으로 처리되도록 감시 상태를 해제한다. // 다음 태깅은 체크아웃이 아니라 새 출석(재출석)으로 처리되도록 감시 상태를 해제한다.
_violationSub = FlutterBackgroundService() _violationSub = FlutterBackgroundService().on('violation_detected').listen((
.on('violation_detected') event,
.listen((event) { ) {
_isWatching = false; _isWatching = false;
notifyListeners(); notifyListeners();
onViolationDetected(event?['pocketNumber']?.toString()); onViolationDetected(event?['pocketNumber']?.toString());
@@ -95,9 +91,9 @@ class NfcPocketCheckinController extends ChangeNotifier {
) { ) {
_isWatching = false; _isWatching = false;
_activePocketNumber = null; _activePocketNumber = null;
_statusMessage = "폰을 회수했습니다. 수고하셨습니다!"; _statusMessage = "✅ 폰을 회수했습니다. 수고하셨습니다!";
notifyListeners(); notifyListeners();
onMessage("폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success); onMessage("✅ 폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success);
}); });
} }
} }
@@ -121,7 +117,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
void _startNfcSession() async { void _startNfcSession() async {
bool isAvailable = await NfcManager.instance.isAvailable(); bool isAvailable = await NfcManager.instance.isAvailable();
if (!isAvailable) { if (!isAvailable) {
_statusMessage = "이 스마트폰은 NFC 기능이 꺼져있거나 지원되지 않습니다."; _statusMessage = "❌ 이 스마트폰은 NFC 기능이 꺼져있거나 지원되지 않습니다.";
notifyListeners(); notifyListeners();
return; return;
} }
@@ -138,7 +134,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
} }
_isProcessing = true; _isProcessing = true;
_statusMessage = "태그 인식 완료! 서버에 출석 전송 중..."; _statusMessage = "⏳ 태그 인식 완료! 서버에 출석 전송 중...";
notifyListeners(); notifyListeners();
try { try {
@@ -167,7 +163,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
tagUid: _getTagUid(tag), tagUid: _getTagUid(tag),
); );
} catch (e) { } catch (e) {
onMessage("태그 읽기 실패: $e", WatchMessageLevel.error); onMessage("❌ 태그 읽기 실패: $e", WatchMessageLevel.error);
} finally { } finally {
// 3초 후 연타 방지 해제 (쿨다운) // 3초 후 연타 방지 해제 (쿨다운)
await Future.delayed(const Duration(seconds: 3)); await Future.delayed(const Duration(seconds: 3));
@@ -220,16 +216,16 @@ class NfcPocketCheckinController extends ChangeNotifier {
onMessage("ℹ️ 이 기기는 백그라운드 감시를 지원하지 않습니다.", WatchMessageLevel.info); onMessage("ℹ️ 이 기기는 백그라운드 감시를 지원하지 않습니다.", WatchMessageLevel.info);
} }
} else { } else {
onMessage("출석 실패: ${result['message']}", WatchMessageLevel.warning); onMessage("⚠️ 출석 실패: ${result['message']}", WatchMessageLevel.warning);
} }
} else { } else {
onMessage( onMessage(
"서버 에러 (코드: ${response.statusCode})", "🚨 서버 에러 (코드: ${response.statusCode})",
WatchMessageLevel.error, WatchMessageLevel.error,
); );
} }
} catch (e) { } catch (e) {
onMessage("서버 연결 실패: $e", WatchMessageLevel.error); onMessage("❌ 서버 연결 실패: $e", WatchMessageLevel.error);
} }
} }
@@ -259,7 +255,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
_isWatching = true; _isWatching = true;
_activePocketNumber = pocketNumber; _activePocketNumber = pocketNumber;
_statusMessage = "백그라운드에서 감시 중입니다. 화면을 꺼도 계속 감시돼요."; _statusMessage = "🛡️ 백그라운드에서 감시 중입니다. 화면을 꺼도 계속 감시돼요.";
notifyListeners(); notifyListeners();
} }
@@ -272,9 +268,9 @@ class NfcPocketCheckinController extends ChangeNotifier {
} }
_isWatching = false; _isWatching = false;
_activePocketNumber = null; _activePocketNumber = null;
_statusMessage = "폰을 회수했습니다. 감시가 종료되었습니다."; _statusMessage = "✅ 폰을 회수했습니다. 감시가 종료되었습니다.";
notifyListeners(); notifyListeners();
onMessage("감시가 종료되었습니다. 수고하셨습니다!", WatchMessageLevel.success); onMessage("✅ 감시가 종료되었습니다. 수고하셨습니다!", WatchMessageLevel.success);
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -295,13 +291,13 @@ class NfcPocketCheckinController extends ChangeNotifier {
_activePocketNumber = pocketNumber; _activePocketNumber = pocketNumber;
_isWatching = true; _isWatching = true;
_statusMessage = "주머니에 넣는 중..."; _statusMessage = "📥 주머니에 넣는 중...";
notifyListeners(); notifyListeners();
try { try {
await _proximityMethodChannel.invokeMethod('start'); await _proximityMethodChannel.invokeMethod('start');
} catch (e) { } catch (e) {
onMessage("근접센서 감시 시작 실패: $e", WatchMessageLevel.error); onMessage("❌ 근접센서 감시 시작 실패: $e", WatchMessageLevel.error);
_isWatching = false; _isWatching = false;
_isIosProximityWatch = false; _isIosProximityWatch = false;
notifyListeners(); notifyListeners();
@@ -311,7 +307,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
// 학생이 폰을 주머니에 넣을 시간을 준 뒤, 그 시점 상태를 기준값으로 삼는다. // 학생이 폰을 주머니에 넣을 시간을 준 뒤, 그 시점 상태를 기준값으로 삼는다.
await Future.delayed(const Duration(seconds: 4)); await Future.delayed(const Duration(seconds: 4));
_statusMessage = "감시 중입니다. 화면을 잠그지 말고 주머니에 넣어주세요."; _statusMessage = "🛡️ 감시 중입니다. 화면을 잠그지 말고 주머니에 넣어주세요.";
notifyListeners(); notifyListeners();
_proximitySub = _proximityEventChannel.receiveBroadcastStream().listen(( _proximitySub = _proximityEventChannel.receiveBroadcastStream().listen((
@@ -363,9 +359,9 @@ class NfcPocketCheckinController extends ChangeNotifier {
if (isPermitted) { if (isPermitted) {
_activePocketNumber = null; _activePocketNumber = null;
_statusMessage = "폰을 회수했습니다. 수고하셨습니다!"; _statusMessage = "✅ 폰을 회수했습니다. 수고하셨습니다!";
notifyListeners(); notifyListeners();
onMessage("폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success); onMessage("✅ 폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success);
return; return;
} }
+9 -9
View File
@@ -37,19 +37,19 @@ class NfcTagWriterController extends ChangeNotifier {
Future<void> startWriteSession(String number) async { Future<void> startWriteSession(String number) async {
if (number.isEmpty) { if (number.isEmpty) {
onMessage("주머니 번호를 입력해주세요.", true); onMessage("⚠️ 주머니 번호를 입력해주세요.", true);
return; return;
} }
final String pocketLabel = "POCKET_$number"; final String pocketLabel = "POCKET_$number";
bool isAvailable = await NfcManager.instance.isAvailable(); bool isAvailable = await NfcManager.instance.isAvailable();
if (!isAvailable) { if (!isAvailable) {
onMessage("NFC가 꺼져있거나 지원되지 않습니다.", true); onMessage("❌ NFC가 꺼져있거나 지원되지 않습니다.", true);
return; return;
} }
_isWriting = true; _isWriting = true;
_statusMessage = "[$pocketLabel] 쓸 준비 완료! 스티커에 폰 뒷면을 대주세요."; _statusMessage = "📡 [$pocketLabel] 쓸 준비 완료! 스티커에 폰 뒷면을 대주세요.";
notifyListeners(); notifyListeners();
NfcManager.instance.startSession( NfcManager.instance.startSession(
@@ -58,11 +58,11 @@ class NfcTagWriterController extends ChangeNotifier {
try { try {
final ndef = Ndef.from(tag); final ndef = Ndef.from(tag);
if (ndef == null) { if (ndef == null) {
onMessage("이 태그는 NDEF 쓰기를 지원하지 않는 종류입니다.", true); onMessage("❌ 이 태그는 NDEF 쓰기를 지원하지 않는 종류입니다.", true);
return; return;
} }
if (!ndef.isWritable) { if (!ndef.isWritable) {
onMessage("이 태그는 쓰기 잠금(read-only) 상태입니다.", true); onMessage("❌ 이 태그는 쓰기 잠금(read-only) 상태입니다.", true);
return; return;
} }
@@ -78,8 +78,8 @@ class NfcTagWriterController extends ChangeNotifier {
onMessage( onMessage(
tagUid != null tagUid != null
? "[$pocketLabel] 쓰기 + UID 등록 성공!" ? "✅ [$pocketLabel] 쓰기 + UID 등록 성공!"
: "[$pocketLabel] 쓰기는 성공했지만 UID를 못 읽어 등록은 안 됐습니다.", : "⚠️ [$pocketLabel] 쓰기는 성공했지만 UID를 못 읽어 등록은 안 됐습니다.",
tagUid == null, tagUid == null,
); );
// 다음 스티커를 연달아 쓰기 편하도록 번호를 자동으로 1 올려준다. // 다음 스티커를 연달아 쓰기 편하도록 번호를 자동으로 1 올려준다.
@@ -88,7 +88,7 @@ class NfcTagWriterController extends ChangeNotifier {
_statusMessage = "다음 번호를 확인하고 '쓰기 시작'을 다시 눌러주세요."; _statusMessage = "다음 번호를 확인하고 '쓰기 시작'을 다시 눌러주세요.";
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
onMessage("쓰기 실패: $e", true); onMessage("❌ 쓰기 실패: $e", true);
} finally { } finally {
await NfcManager.instance.stopSession(); await NfcManager.instance.stopSession();
_isWriting = false; _isWriting = false;
@@ -117,7 +117,7 @@ class NfcTagWriterController extends ChangeNotifier {
body: jsonEncode({"tagUid": tagUid, "pocketNumber": pocketLabel}), body: jsonEncode({"tagUid": tagUid, "pocketNumber": pocketLabel}),
); );
} catch (e) { } 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); final response = await http.delete(url);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final responseData = jsonDecode(response.body); final responseData = jsonDecode(response.body);
return (true, '${responseData['message']}'); return (true, '✅ ${responseData['message']}');
} else { } else {
final errorData = jsonDecode(response.body); final errorData = jsonDecode(response.body);
return (false, '삭제 실패: ${errorData['detail'] ?? '알 수 없는 오류'}'); return (false, '❌ 삭제 실패: ${errorData['detail'] ?? '알 수 없는 오류'}');
} }
} catch (e) { } catch (e) {
return (false, '서버와 연결할 수 없습니다. (네트워크 에러)'); return (false, '❌ 서버와 연결할 수 없습니다. (네트워크 에러)');
} finally { } finally {
_isLoading = false; _isLoading = false;
notifyListeners(); 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(); await fetchAll();
return result['message'] ?? '하교 처리되었습니다.'; return result['message'] ?? '하교 처리되었습니다.';
} catch (e) { } catch (e) {
return '하교 처리 실패: $e'; return '❌ 하교 처리 실패: $e';
} }
} }
@@ -161,7 +161,7 @@ class TeacherAttendanceController extends ChangeNotifier {
await fetchAll(); await fetchAll();
return result['message'] ?? '처리되었습니다.'; return result['message'] ?? '처리되었습니다.';
} catch (e) { } catch (e) {
return '반출 허용 실패: $e'; return '❌ 반출 허용 실패: $e';
} }
} }
@@ -177,7 +177,7 @@ class TeacherAttendanceController extends ChangeNotifier {
await fetchAll(); await fetchAll();
return result['message'] ?? '처리되었습니다.'; return result['message'] ?? '처리되었습니다.';
} catch (e) { } catch (e) {
return '설정 실패: $e'; return '❌ 설정 실패: $e';
} }
} }
@@ -193,7 +193,7 @@ class TeacherAttendanceController extends ChangeNotifier {
await fetchAll(); await fetchAll();
return result['message'] ?? '처리되었습니다.'; return result['message'] ?? '처리되었습니다.';
} catch (e) { } catch (e) {
return '출석시간 설정 실패: $e'; return '❌ 출석시간 설정 실패: $e';
} }
} }
@@ -207,7 +207,7 @@ class TeacherAttendanceController extends ChangeNotifier {
await fetchAll(); await fetchAll();
return result['message'] ?? '처리되었습니다.'; return result['message'] ?? '처리되었습니다.';
} catch (e) { } 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); final res = jsonDecode(response.body);
if (response.statusCode == 200 && res['status'] == 'success') { if (response.statusCode == 200 && res['status'] == 'success') {
return (true, '${res['message']}'); return (true, '✅ ${res['message']}');
} else { } else {
String errorMsg = res['detail'] ?? res['message'] ?? '회원가입에 실패했습니다.'; String errorMsg = res['detail'] ?? res['message'] ?? '회원가입에 실패했습니다.';
return (false, errorMsg); return (false, '❌ $errorMsg');
} }
} catch (e) { } catch (e) {
return (false, '서버와 통신에 실패했습니다.'); return (false, '❌ 서버와 통신에 실패했습니다.');
} finally { } finally {
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
@@ -76,11 +76,11 @@ class TeacherStudentManagementController extends ChangeNotifier {
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
await fetchStudents(); await fetchStudents();
return (true, '$studentName 학생 기기 초기화 완료! (비밀번호도 1234로 초기화됨)'); return (true, '✅ $studentName 학생 기기 초기화 완료! (비밀번호도 1234로 초기화됨)');
} }
return (false, '초기화 통신 실패'); return (false, '❌ 초기화 통신 실패');
} catch (e) { } catch (e) {
return (false, '초기화 통신 실패: $e'); return (false, '❌ 초기화 통신 실패: $e');
} finally { } finally {
_isWorking = false; _isWorking = false;
notifyListeners(); notifyListeners();
@@ -211,12 +211,12 @@ class TeacherStudentManagementController extends ChangeNotifier {
final result = jsonDecode(utf8.decode(response.bodyBytes)); final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') { if (response.statusCode == 200 && result['status'] == 'success') {
return (true, '${result['message'] ?? '계정이 생성되었습니다.'}'); return (true, '✅ ${result['message'] ?? '계정이 생성되었습니다.'}');
} else { } else {
return (false, '생성 실패: ${result['message'] ?? '오류 발생'}'); return (false, '❌ 생성 실패: ${result['message'] ?? '오류 발생'}');
} }
} catch (e) { } catch (e) {
return (false, '네트워크 에러: $e'); return (false, '🚨 네트워크 에러: $e');
} }
} }
@@ -238,12 +238,12 @@ class TeacherStudentManagementController extends ChangeNotifier {
if (response.statusCode == 200 && result['status'] == 'success') { if (response.statusCode == 200 && result['status'] == 'success') {
await fetchStudents(); await fetchStudents();
return (true, '${result['message'] ?? '학년이 지정되었습니다.'}'); return (true, '✅ ${result['message'] ?? '학년이 지정되었습니다.'}');
} else { } else {
return (false, '지정 실패: ${result['message'] ?? '오류 발생'}'); return (false, '❌ 지정 실패: ${result['message'] ?? '오류 발생'}');
} }
} catch (e) { } catch (e) {
return (false, '네트워크 에러: $e'); return (false, '🚨 네트워크 에러: $e');
} finally { } finally {
_isWorking = false; _isWorking = false;
notifyListeners(); notifyListeners();
@@ -266,12 +266,12 @@ class TeacherStudentManagementController extends ChangeNotifier {
if (response.statusCode == 200) { if (response.statusCode == 200) {
final resData = jsonDecode(utf8.decode(response.bodyBytes)); final resData = jsonDecode(utf8.decode(response.bodyBytes));
await fetchStudents(); await fetchStudents();
return (true, '${resData['message']}'); return (true, '✅ ${resData['message']}');
} else { } else {
return (false, '삭제 실패하였습니다.'); return (false, '❌ 삭제 실패하였습니다.');
} }
} catch (e) { } catch (e) {
return (false, '서버 에러 발생'); return (false, '❌ 서버 에러 발생');
} finally { } finally {
_isWorking = false; _isWorking = false;
notifyListeners(); notifyListeners();
+2 -83
View File
@@ -6,11 +6,8 @@ import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'config.dart'; import 'config.dart';
import 'function/session_store.dart';
import 'pocket_watch_service.dart'; import 'pocket_watch_service.dart';
import 'theme/app_palette.dart';
import 'ui/login_screen.dart'; import 'ui/login_screen.dart';
import 'ui/main_dashboard.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -22,18 +19,6 @@ void main() async {
runApp(const SchoolAttendanceApp()); runApp(const SchoolAttendanceApp());
} }
// 🌊 스크롤이 끝에 닿을 때 나오는 파란색 오버스크롤 글로우 효과를 없앤다.
class _NoGlowScrollBehavior extends MaterialScrollBehavior {
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
return child;
}
}
class SchoolAttendanceApp extends StatelessWidget { class SchoolAttendanceApp extends StatelessWidget {
const SchoolAttendanceApp({super.key}); const SchoolAttendanceApp({super.key});
@@ -42,74 +27,8 @@ class SchoolAttendanceApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: '$schoolName 학생 도우미', title: '$schoolName 학생 도우미',
scrollBehavior: _NoGlowScrollBehavior(), theme: ThemeData(primarySwatch: Colors.indigo, useMaterial3: true),
theme: ThemeData( home: const LoginScreen(), // 🚪 앱을 켜면 무조건 로그인 화면이 먼저 등장합니다.
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(), // 🚪 저장된 로그인이 있으면 대시보드로, 없으면 로그인 화면으로.
); );
} }
} }
/// 🔐 "로그인 유지" 진입점. 기기에 저장된 세션이 있는지 확인하는 동안 잠깐 로딩을 보여주고,
/// 있으면 로그인 화면 없이 바로 대시보드로, 없으면 로그인 화면으로 보낸다.
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, autoStartOnBoot: false,
isForegroundMode: true, isForegroundMode: true,
notificationChannelId: pocketWatchNotificationChannelId, notificationChannelId: pocketWatchNotificationChannelId,
initialNotificationTitle: '주머니 감시 대기 중', initialNotificationTitle: '🛡️ 주머니 감시 대기 중',
initialNotificationContent: '출석 태깅을 기다리는 중입니다.', initialNotificationContent: '출석 태깅을 기다리는 중입니다.',
foregroundServiceNotificationId: pocketWatchNotificationId, foregroundServiceNotificationId: pocketWatchNotificationId,
foregroundServiceTypes: [AndroidForegroundType.specialUse], foregroundServiceTypes: [AndroidForegroundType.specialUse],
@@ -88,7 +88,7 @@ void onPocketWatchServiceStart(ServiceInstance service) {
Future<void> reportViolation() async { Future<void> reportViolation() async {
// 진동 3연타로 강하게 경고 (백그라운드 서비스에서는 HapticFeedback이 아닌 vibration 패키지를 써야 동작함) // 진동 3연타로 강하게 경고 (백그라운드 서비스에서는 HapticFeedback이 아닌 vibration 패키지를 써야 동작함)
Vibration.vibrate(pattern: [0, 400, 150, 400, 150, 400]); Vibration.vibrate(pattern: [0, 400, 150, 400, 150, 400]);
updateNotification("무단 반출 감지!", "[$pocketNumber] 주머니에서 폰이 감지되지 않습니다."); updateNotification("🚨 무단 반출 감지!", "[$pocketNumber] 주머니에서 폰이 감지되지 않습니다.");
service.invoke('violation_detected', { service.invoke('violation_detected', {
"pocketNumber": pocketNumber, "pocketNumber": pocketNumber,
"timestamp": DateTime.now().toIso8601String(), "timestamp": DateTime.now().toIso8601String(),
@@ -128,7 +128,7 @@ void onPocketWatchServiceStart(ServiceInstance service) {
} }
if (isPermitted) { if (isPermitted) {
updateNotification("폰 회수 완료", "[$pocketNumber] 주머니에서 정상적으로 회수되었습니다."); updateNotification("✅ 폰 회수 완료", "[$pocketNumber] 주머니에서 정상적으로 회수되었습니다.");
service.invoke('checked_out', {"pocketNumber": pocketNumber}); service.invoke('checked_out', {"pocketNumber": pocketNumber});
service.stopSelf(); service.stopSelf();
} else { } else {
@@ -145,7 +145,7 @@ void onPocketWatchServiceStart(ServiceInstance service) {
baselineLux = baselineLux =
calibrationSamples.reduce((a, b) => a + b) ~/ calibrationSamples.reduce((a, b) => a + b) ~/
calibrationSamples.length; calibrationSamples.length;
updateNotification("감시 중", "[$pocketNumber] 폰이 주머니 안에 있는지 감시 중"); updateNotification("🛡️ 감시 중", "[$pocketNumber] 폰이 주머니 안에 있는지 감시 중");
} }
return; return;
} }
@@ -163,22 +163,22 @@ void onPocketWatchServiceStart(ServiceInstance service) {
baselineLux = null; baselineLux = null;
violated = false; violated = false;
updateNotification("주머니에 넣는 중...", "잠시 후 감시가 시작됩니다."); updateNotification("📥 주머니에 넣는 중...", "잠시 후 감시가 시작됩니다.");
await Future.delayed(_placePhoneDelay); await Future.delayed(_placePhoneDelay);
updateNotification("밝기 측정 중...", "주머니 속 밝기를 기준값으로 설정하는 중입니다."); updateNotification("🔎 밝기 측정 중...", "주머니 속 밝기를 기준값으로 설정하는 중입니다.");
try { try {
await Light().requestAuthorization(); await Light().requestAuthorization();
lightSub = Light().lightSensorStream.listen( lightSub = Light().lightSensorStream.listen(
onLightReading, onLightReading,
onError: (Object error) { onError: (Object error) {
updateNotification("조도 센서 오류", "$error"); updateNotification("❌ 조도 센서 오류", "$error");
}, },
); );
} catch (e) { } catch (e) {
updateNotification("조도 센서 시작 실패", "$e"); updateNotification("❌ 조도 센서 시작 실패", "$e");
} }
} }
+12 -9
View File
@@ -3,8 +3,6 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../config.dart'; import '../config.dart';
import '../theme/app_palette.dart';
import '../ui/app_notice.dart';
import '../ui/login_screen.dart'; import '../ui/login_screen.dart';
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요. // 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
@@ -23,7 +21,9 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
Future<void> _updatePassword() async { Future<void> _updatePassword() async {
String newPw = _pwController.text.trim(); String newPw = _pwController.text.trim();
if (newPw.isEmpty || newPw == "1234") { if (newPw.isEmpty || newPw == "1234") {
AppNotice.show(context, '초기 비밀번호와 다른 안전한 비밀번호를 입력하세요.'); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('⚠️ 초기 비밀번호와 다른 안전한 비밀번호를 입력하세요.')),
);
return; return;
} }
@@ -37,7 +37,9 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
final res = jsonDecode(response.body); final res = jsonDecode(response.body);
if (response.statusCode == 200 && res['status'] == 'success') { if (response.statusCode == 200 && res['status'] == 'success') {
AppNotice.show(context, '비밀번호 변경 완료! 다시 로그인해 주세요.'); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('🔒 비밀번호 변경 완료! 다시 로그인해 주세요.')),
);
// 비밀번호를 바꿨으니 다시 로그인 화면으로 강제 이동 // 비밀번호를 바꿨으니 다시 로그인 화면으로 강제 이동
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
@@ -45,7 +47,9 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
); );
} }
} catch (e) { } catch (e) {
AppNotice.show(context, '통신 실패'); ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('❌ 통신 실패')));
} finally { } finally {
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
@@ -54,7 +58,6 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist,
body: Padding( body: Padding(
padding: const EdgeInsets.all(32.0), padding: const EdgeInsets.all(32.0),
child: Column( child: Column(
@@ -62,7 +65,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
'보안을 위해\n비밀번호를 변경해 주세요', '🔒 보안을 위해\n비밀번호를 변경해 주세요',
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -89,8 +92,8 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
height: 50, height: 50,
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor: Colors.indigo,
foregroundColor: AppPalette.paper, foregroundColor: Colors.white,
), ),
onPressed: _isLoading ? null : _updatePassword, onPressed: _isLoading ? null : _updatePassword,
child: _isLoading 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가 담당한다. // 서버 통신/상태는 lib/function/admin_controller.dart가 담당한다.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/admin_controller.dart'; import '../function/admin_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'login_screen.dart'; import 'login_screen.dart';
import 'title_pill.dart';
// ========================================== // ==========================================
// 🛠️ 5. 관리자 대시보드 (DB 초기화 암호 파라미터 보정 완료) // 🛠️ 5. 관리자 대시보드 (DB 초기화 암호 파라미터 보정 완료)
@@ -29,7 +26,9 @@ class _AdminDashboardState extends State<AdminDashboard> {
Future<void> _resetDatabase() async { Future<void> _resetDatabase() async {
final message = await _controller.resetDatabase(); final message = await _controller.resetDatabase();
if (!mounted) return; if (!mounted) return;
AppNotice.show(context, message); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
} }
void _showResetConfirmDialog() { void _showResetConfirmDialog() {
@@ -38,7 +37,7 @@ class _AdminDashboardState extends State<AdminDashboard> {
builder: (BuildContext dialogContext) { builder: (BuildContext dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text( title: const Text(
'DB 초기화 경고', '⚠️ DB 초기화 경고',
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold), style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
), ),
content: const Text( content: const Text(
@@ -72,13 +71,10 @@ class _AdminDashboardState extends State<AdminDashboard> {
listenable: _controller, listenable: _controller,
builder: (context, _) { builder: (context, _) {
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, title: const Text('🛠️ 관리자 시스템'),
foregroundColor: AppPalette.ink, backgroundColor: Colors.orange,
elevation: 0, foregroundColor: Colors.white,
centerTitle: true,
title: const TitlePill('관리자 시스템'),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.logout), icon: const Icon(Icons.logout),
@@ -92,23 +88,18 @@ class _AdminDashboardState extends State<AdminDashboard> {
body: Center( body: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon( const Icon(
Icons.admin_panel_settings, Icons.admin_panel_settings,
size: 100, size: 100,
color: AppPalette.ink, color: Colors.orange,
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
const Text( const Text(
'데이터베이스 관리', '데이터베이스 관리',
style: TextStyle( style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
fontSize: 22,
fontWeight: FontWeight.bold,
),
), ),
const SizedBox(height: 40), 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 'package:flutter/material.dart';
import '../config.dart' show schoolName; import '../config.dart' show schoolName;
import '../function/login_controller.dart'; import '../function/login_controller.dart';
import '../function/session_store.dart'; import 'student_dashboard.dart';
import '../theme/app_palette.dart'; import 'teacher_dashboard.dart';
import 'app_notice.dart'; import 'admin_dashboard.dart';
import 'main_dashboard.dart';
import 'teacher_register_screen.dart'; import 'teacher_register_screen.dart';
// ------------------------------------------------------------- // -------------------------------------------------------------
@@ -24,7 +23,6 @@ class _LoginScreenState extends State<LoginScreen> {
final LoginController _controller = LoginController(); final LoginController _controller = LoginController();
final TextEditingController _idController = TextEditingController(); final TextEditingController _idController = TextEditingController();
final TextEditingController _pwController = TextEditingController(); final TextEditingController _pwController = TextEditingController();
bool _keepLoggedIn = true;
@override @override
void dispose() { void dispose() {
@@ -46,27 +44,15 @@ class _LoginScreenState extends State<LoginScreen> {
_showErrorDialog(result.errorMessage!); _showErrorDialog(result.errorMessage!);
break; break;
case LoginOutcome.masterSuccess: case LoginOutcome.masterSuccess:
if (_keepLoggedIn) { ScaffoldMessenger.of(context).showSnackBar(
await SessionStore.save( const SnackBar(content: Text('👑 개발자 최고 권한으로 로그인되었습니다.')),
SavedSession(
userId: result.studentId!,
userName: result.name!,
role: 'student',
isDeviceMatched: true,
),
); );
} else {
await SessionStore.clear();
}
if (!mounted) return;
AppNotice.show(context, '개발자 최고 권한으로 로그인되었습니다.');
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => MainDashboard( builder: (context) => StudentDashboard(
userId: result.studentId!, studentId: result.studentId!,
userName: result.name!, studentName: result.name!,
role: 'student',
isDeviceMatched: true, isDeviceMatched: true,
), ),
), ),
@@ -78,17 +64,17 @@ class _LoginScreenState extends State<LoginScreen> {
result.name!, result.name!,
result.role!, result.role!,
result.isDeviceMatched, result.isDeviceMatched,
result.grade,
); );
break; break;
case LoginOutcome.success: case LoginOutcome.success:
AppNotice.show(context, '${result.name}님 환영합니다!'); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('✅ ${result.name}님 환영합니다!')));
_navigateBasedOnRole( _navigateBasedOnRole(
result.role!, result.role!,
result.studentId!, result.studentId!,
result.name!, result.name!,
result.isDeviceMatched, result.isDeviceMatched,
result.grade,
); );
break; break;
} }
@@ -100,7 +86,6 @@ class _LoginScreenState extends State<LoginScreen> {
String name, String name,
String role, String role,
bool isDeviceMatched, bool isDeviceMatched,
int? grade,
) { ) {
final TextEditingController newPwController = TextEditingController(); final TextEditingController newPwController = TextEditingController();
@@ -114,7 +99,7 @@ class _LoginScreenState extends State<LoginScreen> {
builder: (context, setDialogState) { builder: (context, setDialogState) {
return AlertDialog( return AlertDialog(
title: const Text( title: const Text(
'초기 비밀번호 변경', '🔒 초기 비밀번호 변경',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
content: Column( content: Column(
@@ -144,13 +129,17 @@ class _LoginScreenState extends State<LoginScreen> {
) )
: ElevatedButton( : ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor: Colors.indigo,
foregroundColor: Colors.white, foregroundColor: Colors.white,
), ),
onPressed: () async { onPressed: () async {
String newPassword = newPwController.text.trim(); String newPassword = newPwController.text.trim();
if (newPassword.isEmpty) { if (newPassword.isEmpty) {
AppNotice.show(context, '새 비밀번호를 입력해 주세요.'); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('⚠️ 새 비밀번호를 입력해 주세요.'),
),
);
return; return;
} }
@@ -165,16 +154,19 @@ class _LoginScreenState extends State<LoginScreen> {
if (success) { if (success) {
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
AppNotice.show(context, message); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
_navigateBasedOnRole( _navigateBasedOnRole(
role, role,
studentId, studentId,
name, name,
isDeviceMatched, isDeviceMatched,
grade,
); );
} else { } else {
AppNotice.show(context, message); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
} }
}, },
child: const Text('변경하고 시작하기'), child: const Text('변경하고 시작하기'),
@@ -188,47 +180,45 @@ class _LoginScreenState extends State<LoginScreen> {
} }
// 🆕 권한별 화면 이동시 기기 일치 여부 파라미터(`isDeviceMatched`) 수신 및 대시보드 전달 // 🆕 권한별 화면 이동시 기기 일치 여부 파라미터(`isDeviceMatched`) 수신 및 대시보드 전달
Future<void> _navigateBasedOnRole( void _navigateBasedOnRole(
String role, String role,
String studentId, String studentId,
String name, String name,
bool isDeviceMatched, bool isDeviceMatched,
int? grade, ) {
) async { if (role == 'teacher') {
if (_keepLoggedIn) {
await SessionStore.save(
SavedSession(
userId: studentId,
userName: name,
role: role,
isDeviceMatched: isDeviceMatched,
grade: grade,
),
);
} else {
await SessionStore.clear();
}
if (!mounted) return;
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => MainDashboard( builder: (context) =>
userId: studentId, TeacherDashboard(teacherId: studentId, teacherName: name),
userName: name, ),
role: role, );
} 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, isDeviceMatched: isDeviceMatched,
grade: grade,
), ),
), ),
); );
} }
}
void _showErrorDialog(String message) { void _showErrorDialog(String message) {
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text( title: const Text(
'인증 실패', '⚠️ 인증 실패',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
content: Text(message), content: Text(message),
@@ -253,7 +243,7 @@ class _LoginScreenState extends State<LoginScreen> {
barrierDismissible: false, barrierDismissible: false,
builder: (BuildContext dialogContext) { builder: (BuildContext dialogContext) {
return AlertDialog( return AlertDialog(
title: Text('$title 권한 인증 (기존 방식)'), title: Text('🔒 $title 권한 인증 (기존 방식)'),
content: TextField( content: TextField(
controller: passwordController, controller: passwordController,
obscureText: true, obscureText: true,
@@ -267,7 +257,9 @@ class _LoginScreenState extends State<LoginScreen> {
MaterialPageRoute(builder: (context) => nextPage), MaterialPageRoute(builder: (context) => nextPage),
); );
} else { } else {
AppNotice.show(context, '비밀번호가 올바르지 않습니다.'); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('❌ 비밀번호가 올바르지 않습니다.')),
);
} }
}, },
decoration: const InputDecoration( decoration: const InputDecoration(
@@ -289,7 +281,9 @@ class _LoginScreenState extends State<LoginScreen> {
MaterialPageRoute(builder: (context) => nextPage), MaterialPageRoute(builder: (context) => nextPage),
); );
} else { } else {
AppNotice.show(context, '비밀번호가 올바르지 않습니다.'); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('❌ 비밀번호가 올바르지 않습니다.')),
);
} }
}, },
child: const Text('인증하기'), child: const Text('인증하기'),
@@ -306,7 +300,7 @@ class _LoginScreenState extends State<LoginScreen> {
listenable: _controller, listenable: _controller,
builder: (context, _) { builder: (context, _) {
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist, backgroundColor: Colors.grey[50],
body: Center( body: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
@@ -315,14 +309,14 @@ class _LoginScreenState extends State<LoginScreen> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.school, size: 80, color: AppPalette.ink), const Icon(Icons.school, size: 80, color: Colors.indigo),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'$schoolName 모니터', '$schoolName 모니터',
style: const TextStyle( style: const TextStyle(
fontSize: 26, fontSize: 26,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppPalette.ink, color: Colors.indigo,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -352,31 +346,7 @@ class _LoginScreenState extends State<LoginScreen> {
prefixIcon: Icon(Icons.lock), prefixIcon: Icon(Icons.lock),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 24),
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),
_controller.isLoading _controller.isLoading
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: SizedBox( : SizedBox(
@@ -384,7 +354,7 @@ class _LoginScreenState extends State<LoginScreen> {
height: 55, height: 55,
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor: Colors.indigo,
foregroundColor: Colors.white, foregroundColor: Colors.white,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -409,9 +379,9 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
child: const Text( child: const Text(
'선생님이신가요? 교사 회원가입 하기', '👨‍🏫 선생님이신가요? 교사 회원가입 하기',
style: TextStyle( style: TextStyle(
color: AppPalette.ink, color: Colors.indigo,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -433,7 +403,7 @@ class _LoginScreenState extends State<LoginScreen> {
onPressed: () => _showLegacyPasswordDialog( onPressed: () => _showLegacyPasswordDialog(
'선생님', '선생님',
'1234', '1234',
const MainDashboard(role: 'teacher'), const TeacherDashboard(),
), ),
), ),
TextButton.icon( TextButton.icon(
@@ -449,7 +419,7 @@ class _LoginScreenState extends State<LoginScreen> {
onPressed: () => _showLegacyPasswordDialog( onPressed: () => _showLegacyPasswordDialog(
'시스템 관리자', '시스템 관리자',
'4936', '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/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/nfc_pocket_checkin_controller.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 { class NfcPocketCheckInScreen extends StatefulWidget {
final String studentId; // 로그인된 학생 학번 (예: "2061") final String studentId; // 로그인된 학생 학번 (예: "2061")
@@ -75,7 +72,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: Colors.red[50], backgroundColor: Colors.red[50],
title: const Text( title: const Text(
"무단 반출 감지", "🚨 무단 반출 감지",
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold), style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
), ),
content: Text( content: Text(
@@ -101,7 +98,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text("감시 모드 켜기 전에"), title: const Text("🛡️ 감시 모드 켜기 전에"),
content: const Text( content: const Text(
"iOS는 화면을 잠그면 감시가 끊겨요. 아래 순서로 '가이드 접근'을 먼저 켜주세요.\n\n" "iOS는 화면을 잠그면 감시가 끊겨요. 아래 순서로 '가이드 접근'을 먼저 켜주세요.\n\n"
"1. 사이드(또는 홈) 버튼 3번 빠르게 누르기\n" "1. 사이드(또는 홈) 버튼 3번 빠르게 누르기\n"
@@ -130,9 +127,9 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text("자습실 출석 완료!"), title: const Text("🎉 자습실 출석 완료!"),
content: Text( content: Text(
"${widget.studentName} 학생!\n[$pocketNumber] 주머니 출석이 확인되었습니다.\n\n폰을 주머니에 쏙 넣고 자습에 집중해 주세요!", "${widget.studentName} 학생!\n[$pocketNumber] 주머니 출석이 확인되었습니다.\n\n폰을 주머니에 쏙 넣고 자습에 집중해 주세요! ✏️",
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -146,7 +143,9 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
void _showSnackBar(String text, Color color) { void _showSnackBar(String text, Color color) {
if (!mounted) return; if (!mounted) return;
AppNotice.show(context, text, color: color); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(text), backgroundColor: color));
} }
@override @override
@@ -160,11 +159,11 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, title: const Text(
foregroundColor: AppPalette.ink, "자습실 NFC 출석체크",
elevation: 0, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
centerTitle: true, ),
title: const TitlePill("자습실 NFC 출석체크"), backgroundColor: const Color.fromARGB(255, 48, 48, 52),
), ),
body: Center( body: Center(
child: Padding( child: Padding(
@@ -259,8 +258,8 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
!kIsWeb && Platform.isIOS !kIsWeb && Platform.isIOS
? "가이드 접근을 켠 채로 화면만 잠기도록 두세요.\n(임의로 앱을 나가면 감시가 끊깁니다)\n감시를 끝내려면 가이드 접근을 먼저 끄고 다시 태깅하세요." ? "🔒 가이드 접근을 켠 채로 화면만 잠기도록 두세요.\n(임의로 앱을 나가면 감시가 끊깁니다)\n감시를 끝내려면 가이드 접근을 먼저 끄고 다시 태깅하세요."
: "화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)", : "📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white38, fontSize: 13), style: const TextStyle(color: Colors.white38, fontSize: 13),
), ),
+11 -17
View File
@@ -2,9 +2,6 @@
// 버튼/입력폼을 그린다. NFC 세션/서버 통신은 lib/function/nfc_tag_writer_controller.dart가 담당한다. // 버튼/입력폼을 그린다. NFC 세션/서버 통신은 lib/function/nfc_tag_writer_controller.dart가 담당한다.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/nfc_tag_writer_controller.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 { class NfcTagWriterScreen extends StatefulWidget {
const NfcTagWriterScreen({super.key}); const NfcTagWriterScreen({super.key});
@@ -37,11 +34,11 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
void _showSnackBar(String text, bool isError) { void _showSnackBar(String text, bool isError) {
if (!mounted) return; if (!mounted) return;
AppNotice.show( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
text, content: Text(text),
color: isError ? Colors.red[700] : Colors.green[700], backgroundColor: isError ? Colors.red : Colors.green,
icon: isError ? Icons.error_outline_rounded : Icons.check_circle_rounded, ),
); );
} }
@@ -52,13 +49,10 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
builder: (context, _) { builder: (context, _) {
final bool isWriting = _controller.isWriting; final bool isWriting = _controller.isWriting;
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, title: const Text("🏷️ NFC 주머니 태그 쓰기"),
foregroundColor: AppPalette.ink, backgroundColor: Colors.deepPurple,
elevation: 0, foregroundColor: Colors.white,
centerTitle: true,
title: const TitlePill("NFC 주머니 태그 쓰기"),
), ),
body: Padding( body: Padding(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
@@ -79,7 +73,7 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
Icon( Icon(
isWriting ? Icons.nfc : Icons.edit_note_rounded, isWriting ? Icons.nfc : Icons.edit_note_rounded,
size: 80, size: 80,
color: isWriting ? Colors.orange : AppPalette.ink, color: isWriting ? Colors.orange : Colors.deepPurple,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
@@ -93,8 +87,8 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
height: 55, height: 55,
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor: Colors.deepPurple,
foregroundColor: AppPalette.paper, foregroundColor: Colors.white,
), ),
onPressed: isWriting onPressed: isWriting
? null ? 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가 담당한다. // 서버 통신·상태·파생 로직은 lib/function/teacher_attendance_controller.dart가 담당한다.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/teacher_attendance_controller.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 카드 스타일 리스트화) // 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화)
@@ -32,11 +28,13 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
super.dispose(); super.dispose();
} }
/// 컨트롤러 액션을 실행하고, 결과 메시지를 알약형 알림으로 보여준다. /// 컨트롤러 액션을 실행하고, 결과 메시지를 스낵바로 보여준다.
Future<void> _runAction(Future<String> Function() action) async { Future<void> _runAction(Future<String> Function() action) async {
final message = await action(); final message = await action();
if (!mounted) return; if (!mounted) return;
AppNotice.show(context, message); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
} }
Future<void> _showAttendanceTimeDialog() async { Future<void> _showAttendanceTimeDialog() async {
@@ -64,7 +62,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('테스트용 허용시간 초기화'), title: const Text('🧪 테스트용 허용시간 초기화'),
content: const Text( content: const Text(
'하교 처리나 반출 허용 시간 설정으로 켜져 있는 모든 허용 시간대를 지금 즉시 해제합니다.\n' '하교 처리나 반출 허용 시간 설정으로 켜져 있는 모든 허용 시간대를 지금 즉시 해제합니다.\n'
'(무단반출 감지 테스트할 때만 사용하세요)', '(무단반출 감지 테스트할 때만 사용하세요)',
@@ -127,7 +125,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('전체 학생 반출 허용 시간 설정'), title: const Text('⏰ 전체 학생 반출 허용 시간 설정'),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -170,7 +168,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('하교 처리'), title: const Text('🏫 하교 처리'),
content: const Text( content: const Text(
'지금부터 모든 학생의 반출이 자동으로 허용되며, 더 이상 무단반출 경고가 뜨지 않습니다.\n하교 처리하시겠습니까?', '지금부터 모든 학생의 반출이 자동으로 허용되며, 더 이상 무단반출 경고가 뜨지 않습니다.\n하교 처리하시겠습니까?',
), ),
@@ -204,21 +202,67 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
final int absentCount = totalCount - checkedInCount; final int absentCount = totalCount - checkedInCount;
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist, backgroundColor: Colors.grey[100],
// 🪶 검정 배너 대신, 제목 알약은 "전체 학생 수" 버튼 바로 위(사이드바/모바일 요약 위)에 둔다.
// 나머지 기능은 전부 "자습실 시간 설정 메뉴"(런치패드 스타일 오버레이) 하나로 모은다.
// 폰에서는 사이드바가 없으니 여기 아이콘으로도 같은 메뉴를 열 수 있게 둔다.
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, title: const Text(
foregroundColor: AppPalette.ink, '📋 실시간 출석 현황',
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
elevation: 0, elevation: 0,
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.more_horiz_rounded), onPressed: (_controller.isLoading || _controller.isRefreshing)
tooltip: '자습실 시간 설정 메뉴', ? null
onPressed: _showTimeSettingsMenu, : _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 body: _controller.isLoading
@@ -251,30 +295,9 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
final groups = _controller.studentsByGrade; final groups = _controller.studentsByGrade;
return Column( return Column(
children: [ children: [
const SizedBox(height: 12), _buildSummaryCards(total, checkedIn, absent),
const TitlePill('실시간 출석 현황'),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
Expanded(child: _sidebarStatPill('전체 학생 수', total, 'ALL')),
const SizedBox(width: 8),
Expanded(
child: _sidebarStatPill('출석 학생 수', checkedIn, 'CHECKED_IN'),
),
const SizedBox(width: 8),
Expanded(child: _sidebarStatPill('미출석 학생수', absent, 'ABSENT')),
],
),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: _sidebarFilterButton(),
),
_buildPermissionBanner(), _buildPermissionBanner(),
const SizedBox(height: 12), _buildFilterChips(),
Expanded( Expanded(
child: groups.isEmpty child: groups.isEmpty
? const Center( ? const Center(
@@ -313,7 +336,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
width: 4, width: 4,
height: 16, height: 16,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppPalette.ink, color: Colors.blue,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
@@ -323,7 +346,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppPalette.ink, color: Colors.black87,
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
@@ -363,9 +386,9 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
? Colors.red ? Colors.red
: (isPending ? Colors.orange : (isComplete ? Colors.blue : Colors.red)); : (isPending ? Colors.orange : (isComplete ? Colors.blue : Colors.red));
final String statusLine = hasViolation final String statusLine = hasViolation
? '무단반출 (${student['violationTime']})' ? '🚨 무단반출 (${student['violationTime']})'
: (isPending : (isPending
? '미완료 (${student['checkInTime']})' ? '⏳ 미완료 (${student['checkInTime']})'
: (isComplete : (isComplete
? '${student['checkInTime']}' ? '${student['checkInTime']}'
: (student['hasEverCheckedIn'] == true ? '미제출' : '미등록'))); : (student['hasEverCheckedIn'] == true ? '미제출' : '미등록')));
@@ -373,12 +396,11 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
return Container( return Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: hasViolation ? Colors.red[50] : AppPalette.paper, color: hasViolation ? Colors.red[50] : Colors.white,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: hasViolation
color: hasViolation ? Colors.red[300]! : AppPalette.sage, ? Border.all(color: Colors.red[300]!, width: 1.5)
width: hasViolation ? 1.5 : 1, : null,
),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.03), color: Colors.black.withValues(alpha: 0.03),
@@ -418,15 +440,15 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
vertical: 3, vertical: 3,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppPalette.linen, color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppPalette.sage), border: Border.all(color: Colors.blue.shade200),
), ),
child: Text( child: Text(
student['pocketNumber'], student['pocketNumber'],
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppPalette.ink, color: Colors.blueAccent,
fontSize: 11, fontSize: 11,
), ),
), ),
@@ -485,24 +507,51 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때) // 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
// 왼쪽에 요약/필터 사이드바, 오른쪽에 좌석표처럼 촘촘한 학생 칸 그리드를 둬서
// 80명이 넘는 인원도 스크롤을 최소화하고 한눈에 볼 수 있게 한다.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
Widget _buildDesktopBody(int total, int checkedIn, int absent) { Widget _buildDesktopBody(int total, int checkedIn, int absent) {
final students = _controller.filteredStudents; final groups = _controller.studentsByGrade;
return Padding( return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100),
child: Padding(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildPermissionBanner(), Row(
const SizedBox(height: 12),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildDesktopSidebar(total, checkedIn, absent), Expanded(
const SizedBox(width: 20), 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( Expanded(
child: Container( child: Container(
width: double.infinity, 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 _statTile(String label, String value, IconData icon, Color color) {
Widget _buildDesktopSidebar(int total, int checkedIn, int absent) { return Container(
return SizedBox( padding: const EdgeInsets.all(20),
width: 200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(child: const TitlePill('실시간 출석 현황')),
const SizedBox(height: 16),
_sidebarStatPill('전체 학생 수', total, 'ALL'),
const SizedBox(height: 10),
_sidebarStatPill('출석 학생 수', checkedIn, 'CHECKED_IN'),
const SizedBox(height: 10),
_sidebarStatPill('미출석 학생수', absent, 'ABSENT'),
const SizedBox(height: 20),
_sidebarFilterButton(),
const SizedBox(height: 10),
_sidebarTimeSettingsButton(),
],
),
);
}
Widget _sidebarTimeSettingsButton() {
return InkWell(
onTap: _showTimeSettingsMenu,
borderRadius: BorderRadius.circular(28),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppPalette.paper, color: Colors.white,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppPalette.sage), boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon( Container(
Icons.access_time_rounded, padding: const EdgeInsets.all(12),
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),
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected ? AppPalette.ink : AppPalette.paper, color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? AppPalette.ink : AppPalette.sage,
), ),
child: Icon(icon, color: color, size: 26),
), ),
child: Column( const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
label, label,
style: TextStyle(color: Colors.grey[600], fontSize: 13),
),
const SizedBox(height: 2),
Text(
value,
style: TextStyle( style: TextStyle(
fontSize: 12, color: color,
color: selected ? Colors.white70 : Colors.grey[600], 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), const SizedBox(height: 4),
Text( Text(
'$count명', count,
style: TextStyle( style: TextStyle(
fontSize: 18, color: color,
fontWeight: FontWeight.bold, fontSize: 20,
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],
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
], ],
),
),
); );
} }
@@ -859,7 +689,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'지금 전체 반출 허용 중입니다 ($until 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.', '🔓 지금 전체 반출 허용 중입니다 ($until 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.',
style: TextStyle( style: TextStyle(
color: Colors.amber[900], color: Colors.amber[900],
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -871,116 +701,63 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
), ),
); );
} }
}
// 🚀 "자습실 시간 설정 메뉴" 카드 — 메인 대시보드 로그아웃/설정 메뉴와 같은 톤으로 통일. /// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자)
class _TimeSettingsMenuCard extends StatelessWidget { Widget _buildFilterChips() {
final bool isRefreshing; return Padding(
final String attendanceTimeLabel; padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
final ValueChanged<String> onPick;
const _TimeSettingsMenuCard({
required this.isRefreshing,
required this.attendanceTimeLabel,
required this.onPick,
});
@override
Widget build(BuildContext context) {
return Material(
color: AppPalette.paper,
elevation: 8,
shadowColor: Colors.black.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(20),
child: IntrinsicWidth(
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 220),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_TimeMenuRow( Wrap(
icon: isRefreshing ? null : Icons.refresh_rounded, spacing: 8,
label: '새로고침', runSpacing: 8,
onTap: () => onPick('refresh'), children: [
FilterChip(
label: const Text("전체"),
selected: _controller.filterType == "ALL",
onSelected: (_) => _controller.setFilter("ALL"),
), ),
const Divider(height: 1, color: AppPalette.sage), FilterChip(
_TimeMenuRow( label: const Text("🟢 출석자"),
icon: Icons.access_time_rounded, selected: _controller.filterType == "CHECKED_IN",
label: attendanceTimeLabel, onSelected: (_) => _controller.setFilter("CHECKED_IN"),
onTap: () => onPick('attendance_time'),
), ),
const Divider(height: 1, color: AppPalette.sage), FilterChip(
_TimeMenuRow( label: const Text("🔴 미출석자"),
icon: Icons.timer_outlined, selected: _controller.filterType == "ABSENT",
label: '반출 허용 시간 설정', onSelected: (_) => _controller.setFilter("ABSENT"),
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'),
), ),
], ],
), ),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ChoiceChip(
label: const Text("학년 전체"),
selected: _controller.gradeFilter == "ALL",
onSelected: (_) => _controller.setGradeFilter("ALL"),
), ),
ChoiceChip(
label: const Text("1학년"),
selected: _controller.gradeFilter == "1",
onSelected: (_) => _controller.setGradeFilter("1"),
), ),
); ChoiceChip(
} label: const Text("2학년"),
} selected: _controller.gradeFilter == "2",
onSelected: (_) => _controller.setGradeFilter("2"),
class _TimeMenuRow extends StatelessWidget {
final IconData? icon;
final String label;
final VoidCallback onTap;
final bool dim;
const _TimeMenuRow({
required this.icon,
required this.label,
required this.onTap,
this.dim = false,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 20,
height: 20,
child: icon == null
? const CircularProgressIndicator(strokeWidth: 2)
: Icon(
icon,
size: 20,
color: dim ? Colors.grey : AppPalette.ink,
), ),
ChoiceChip(
label: const Text("3학년"),
selected: _controller.gradeFilter == "3",
onSelected: (_) => _controller.setGradeFilter("3"),
), ),
const SizedBox(width: 12),
Flexible(
child: Text(
label,
style: TextStyle(
fontWeight: FontWeight.w600,
color: dim ? Colors.grey[600] : AppPalette.ink,
),
),
),
], ],
), ),
],
), ),
); );
} }
-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가 담당한다. // lib/function/teacher_register_controller.dart가 담당한다.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/teacher_register_controller.dart'; import '../function/teacher_register_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class TeacherRegisterScreen extends StatefulWidget { class TeacherRegisterScreen extends StatefulWidget {
const TeacherRegisterScreen({super.key}); const TeacherRegisterScreen({super.key});
@@ -37,7 +34,9 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
String secret = _secretController.text.trim(); String secret = _secretController.text.trim();
if (id.isEmpty || pw.isEmpty || name.isEmpty || secret.isEmpty) { if (id.isEmpty || pw.isEmpty || name.isEmpty || secret.isEmpty) {
AppNotice.show(context, '모든 빈칸을 입력해 주세요.'); ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('⚠️ 모든 빈칸을 입력해 주세요.')));
return; return;
} }
@@ -48,7 +47,9 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
secretCode: secret, secretCode: secret,
); );
if (!mounted) return; if (!mounted) return;
AppNotice.show(context, message); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
if (success) { if (success) {
Navigator.pop(context); // 가입 성공 시 로그인 화면으로 복귀 Navigator.pop(context); // 가입 성공 시 로그인 화면으로 복귀
} }
@@ -60,13 +61,10 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
listenable: _controller, listenable: _controller,
builder: (context, _) { builder: (context, _) {
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, title: const Text('교사 회원가입'),
foregroundColor: AppPalette.ink, backgroundColor: Colors.indigo,
elevation: 0, foregroundColor: Colors.white,
centerTitle: true,
title: const TitlePill('교사 회원가입'),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
@@ -74,7 +72,7 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
'교직원 전용 인증', '👨‍🏫 교직원 전용 인증',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -87,7 +85,7 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
controller: _secretController, controller: _secretController,
obscureText: true, obscureText: true,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: '교사 인증 비밀코드 입력', labelText: '🔑 교사 인증 비밀코드 입력',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
), ),
@@ -124,8 +122,8 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
height: 50, height: 50,
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor: Colors.indigo,
foregroundColor: AppPalette.paper, foregroundColor: Colors.white,
), ),
onPressed: _controller.isLoading ? null : _registerTeacher, onPressed: _controller.isLoading ? null : _registerTeacher,
child: _controller.isLoading child: _controller.isLoading
+249 -312
View File
@@ -5,13 +5,6 @@ import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/excel_file_picker.dart'; import '../function/excel_file_picker.dart';
import '../function/teacher_student_management_controller.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] 학생 계정 관리 란 (추가 양식 폼 + 강제 삭제 다이얼로그 완전 내장) // 👥 [서브 화면 2] 학생 계정 관리 란 (추가 양식 폼 + 강제 삭제 다이얼로그 완전 내장)
@@ -30,6 +23,7 @@ class _TeacherStudentManagementPageState
TeacherStudentManagementController(); TeacherStudentManagementController();
final TextEditingController _addIdController = TextEditingController(); final TextEditingController _addIdController = TextEditingController();
final TextEditingController _addNameController = TextEditingController(); final TextEditingController _addNameController = TextEditingController();
final TextEditingController _addPwController = TextEditingController();
int? _selectedGrade; int? _selectedGrade;
bool _isDragging = false; bool _isDragging = false;
@@ -44,32 +38,39 @@ class _TeacherStudentManagementPageState
_controller.dispose(); _controller.dispose();
_addIdController.dispose(); _addIdController.dispose();
_addNameController.dispose(); _addNameController.dispose();
_addPwController.dispose();
super.dispose(); super.dispose();
} }
// ➕ [학생 추가 버튼 동작] 기본 비밀번호는 항상 1234로 고정. // ➕ [학생 추가 버튼 동작]
Future<void> _addStudentAccount() async { Future<void> _addStudentAccount() async {
final sId = _addIdController.text.trim(); final sId = _addIdController.text.trim();
final sName = _addNameController.text.trim(); final sName = _addNameController.text.trim();
final sPw = _addPwController.text.trim();
if (sId.isEmpty || sName.isEmpty) { if (sId.isEmpty || sName.isEmpty || sPw.isEmpty) {
AppNotice.show(context, '모든 입력란을 채워주세요.'); ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('⚠️ 모든 입력란을 채워주세요.')));
return; return;
} }
final (success, message) = await _controller.addStudentAccount( final (success, message) = await _controller.addStudentAccount(
studentId: sId, studentId: sId,
name: sName, name: sName,
password: '1234', password: sPw,
grade: _selectedGrade, grade: _selectedGrade,
); );
if (!mounted) return; if (!mounted) return;
if (success) { if (success) {
_addIdController.clear(); _addIdController.clear();
_addNameController.clear(); _addNameController.clear();
_addPwController.clear();
setState(() => _selectedGrade = null); 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); rows = _controller.parseExcelBytes(bytes);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
AppNotice.show(context, '엑셀 파일을 읽는 데 실패했습니다: $e'); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('❌ 엑셀 파일을 읽는 데 실패했습니다: $e')));
return; return;
} }
if (rows.isEmpty) { if (rows.isEmpty) {
if (!mounted) return; if (!mounted) return;
AppNotice.show(context, '유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)'); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('⚠️ 유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)'),
),
);
return; return;
} }
_showBulkPreviewDialog(rows); _showBulkPreviewDialog(rows);
@@ -104,7 +111,7 @@ class _TeacherStudentManagementPageState
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Text('${rows.length}명 확인됨'), title: Text('📄 ${rows.length}명 확인됨'),
content: SizedBox( content: SizedBox(
width: 400, width: 400,
child: Column( child: Column(
@@ -155,8 +162,8 @@ class _TeacherStudentManagementPageState
_runBulkImport(rows); _runBulkImport(rows);
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor: Colors.orange,
foregroundColor: AppPalette.paper, foregroundColor: Colors.white,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@@ -201,7 +208,9 @@ class _TeacherStudentManagementPageState
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Text(result.failures.isEmpty ? '일괄 등록 완료' : '일괄 등록 완료 (일부 실패)'), title: Text(
result.failures.isEmpty ? '✅ 일괄 등록 완료' : '⚠️ 일괄 등록 완료 (일부 실패)',
),
content: SizedBox( content: SizedBox(
width: 400, width: 400,
child: Column( child: Column(
@@ -274,7 +283,9 @@ class _TeacherStudentManagementPageState
grade: selected, grade: selected,
); );
if (!mounted) return; if (!mounted) return;
AppNotice.show(context, message); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
} }
// 🔓 [목록 행의 "기기 리셋" 버튼] 학생이 폰을 바꿨을 때 등록 초기화. // 🔓 [목록 행의 "기기 리셋" 버튼] 학생이 폰을 바꿨을 때 등록 초기화.
@@ -283,8 +294,8 @@ class _TeacherStudentManagementPageState
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text( title: const Text(
'기기 등록 초기화', '⚠️ 기기 등록 초기화',
style: TextStyle(fontWeight: FontWeight.bold, color: AppPalette.ink), style: TextStyle(fontWeight: FontWeight.bold, color: Colors.purple),
), ),
content: Text('$studentName 학생의 스마트폰 기기 등록과 비밀번호(1234)를 초기화하시겠습니까?'), content: Text('$studentName 학생의 스마트폰 기기 등록과 비밀번호(1234)를 초기화하시겠습니까?'),
actions: [ actions: [
@@ -294,8 +305,8 @@ class _TeacherStudentManagementPageState
), ),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor: Colors.purple,
foregroundColor: AppPalette.paper, foregroundColor: Colors.white,
), ),
onPressed: () async { onPressed: () async {
Navigator.pop(ctx); Navigator.pop(ctx);
@@ -304,7 +315,9 @@ class _TeacherStudentManagementPageState
studentName, studentName,
); );
if (!mounted) return; if (!mounted) return;
AppNotice.show(context, message); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}, },
child: const Text('초기화 승인'), child: const Text('초기화 승인'),
), ),
@@ -348,7 +361,9 @@ class _TeacherStudentManagementPageState
studentId, studentId,
); );
if (!mounted) return; if (!mounted) return;
AppNotice.show(context, message); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}, },
child: const Text('영구 삭제'), child: const Text('영구 삭제'),
), ),
@@ -357,37 +372,56 @@ class _TeacherStudentManagementPageState
); );
} }
// 🏷️ 섹션 제목 (색 인디케이터 바 + 텍스트). @override
Widget _sectionTitle(String text, {Color color = AppPalette.ink}) { Widget build(BuildContext context) {
return Row( 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: [ children: [
Container( Container(
width: 4, width: 4,
height: 16, height: 16,
decoration: BoxDecoration( decoration: BoxDecoration(
color: color, color: Colors.orange,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( const Text(
text, '신규 학생 계정 추가',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
), ),
], ],
); ),
} const SizedBox(height: 16),
// 📝 [정사각형 박스 1] 신규 학생 계정 추가 폼. 박스가 작아도 버튼까지 다 보이도록 촘촘하게 배치. // 📝 학생 추가 컨테이너 폼
Widget _buildAddAccountBox() { Container(
const InputDecoration Function(String, IconData) fieldDecoration = padding: const EdgeInsets.all(20),
_compactFieldDecoration;
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppPalette.paper, color: Colors.white,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppPalette.sage),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.04), color: Colors.black.withValues(alpha: 0.04),
@@ -395,76 +429,72 @@ class _TeacherStudentManagementPageState
), ),
], ],
), ),
child: SingleChildScrollView(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
TextField( TextField(
controller: _addIdController, controller: _addIdController,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
style: const TextStyle(fontSize: 12), decoration: const InputDecoration(
decoration: fieldDecoration('학번', Icons.badge), labelText: '학번',
prefixIcon: Icon(Icons.badge),
), ),
const SizedBox(height: 4), ),
const SizedBox(height: 12),
TextField( TextField(
controller: _addNameController, controller: _addNameController,
style: const TextStyle(fontSize: 12), decoration: const InputDecoration(
decoration: fieldDecoration('이름', Icons.person), labelText: '이름',
), prefixIcon: Icon(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),
),
],
), ),
), ),
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>( DropdownButtonFormField<int>(
initialValue: _selectedGrade, initialValue: _selectedGrade,
style: const TextStyle(fontSize: 12, color: Colors.black87), decoration: const InputDecoration(
decoration: fieldDecoration('학년 (선택)', Icons.class_), labelText: '학년 (선택)',
prefixIcon: Icon(Icons.class_),
),
items: const [ items: const [
DropdownMenuItem(value: 1, child: Text('1학년')), DropdownMenuItem(value: 1, child: Text('1학년')),
DropdownMenuItem(value: 2, child: Text('2학년')), DropdownMenuItem(value: 2, child: Text('2학년')),
DropdownMenuItem(value: 3, child: Text('3학년')), 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( SizedBox(
width: double.infinity, width: double.infinity,
height: 30, height: 50,
child: ElevatedButton( child: ElevatedButton(
onPressed: _controller.isWorking ? null : _addStudentAccount, onPressed: _controller.isWorking
? null
: _addStudentAccount,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor: Colors.orange,
foregroundColor: AppPalette.paper, foregroundColor: Colors.white,
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(12),
), ),
), ),
child: _controller.isWorking child: _controller.isWorking
? const SizedBox( ? const CircularProgressIndicator(
width: 16,
height: 16,
child: CircularProgressIndicator(
color: Colors.white, color: Colors.white,
strokeWidth: 2,
),
) )
: const Text( : const Text(
'학생 등록 완료', '학생 등록 완료',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, 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( Row(
labelText: label, children: [
labelStyle: const TextStyle(fontSize: 12), Container(
prefixIcon: Icon(icon, size: 16), width: 4,
isDense: true, height: 16,
contentPadding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), 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] 엑셀 일괄 등록 드롭존. DropTarget(
Widget _buildExcelBox() {
return DropTarget(
onDragEntered: (_) => setState(() => _isDragging = true), onDragEntered: (_) => setState(() => _isDragging = true),
onDragExited: (_) => setState(() => _isDragging = false), onDragExited: (_) => setState(() => _isDragging = false),
onDragDone: (details) async { onDragDone: (details) async {
@@ -502,238 +542,71 @@ class _TeacherStudentManagementPageState
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
child: Container( child: Container(
width: double.infinity, width: double.infinity,
height: double.infinity, padding: const EdgeInsets.symmetric(vertical: 28),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _isDragging ? Colors.orange[50] : AppPalette.paper, color: _isDragging ? Colors.orange[50] : Colors.white,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
border: Border.all( border: Border.all(
color: _isDragging ? Colors.orange : AppPalette.sage, color: _isDragging
? Colors.orange
: Colors.grey.shade300,
width: _isDragging ? 2 : 1, width: _isDragging ? 2 : 1,
), ),
), ),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
Icons.upload_file_rounded, Icons.upload_file_rounded,
size: 40, size: 40,
color: _isDragging ? Colors.orange : AppPalette.sage, color: _isDragging
? Colors.orange
: Colors.grey[400],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
_isDragging ? '여기에 놓으세요' : '엑셀 파일을 드래그하거나 눌러서 선택', _isDragging ? '여기에 놓으세요' : '엑셀 파일을 드래그하거나 눌러서 선택',
textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: _isDragging ? Colors.orange[800] : AppPalette.ink, color: _isDragging
? Colors.orange[800]
: Colors.black87,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'.xlsx · 1행은 머리글, A열=학번 B열=이름 C열=학년(선택)', '.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( style: TextStyle(
fontSize: 11.5, fontSize: 12,
color: needsReset ? Colors.red[700] : Colors.grey[600], color: Colors.grey[500],
fontWeight: needsReset ? FontWeight.bold : FontWeight.normal,
), ),
), textAlign: TextAlign.center,
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),
), ),
], ],
), ),
],
), ),
);
}
@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), const SizedBox(height: 36),
// 🏷️ 인디케이터 바 (전체 학생 목록) — 위 두 칸을 합친 폭에 맞춘다. // 🏷️ 인디케이터 바 (전체 학생 목록)
SizedBox(
width: listWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row( Row(
children: [ 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(), const Spacer(),
IconButton( IconButton(
icon: const Icon(Icons.refresh_rounded), icon: const Icon(Icons.refresh_rounded),
@@ -747,18 +620,14 @@ class _TeacherStudentManagementPageState
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( const Text(
'학년 칩을 눌러 학년을 바꾸고, 기기 리셋은 학생이 폰을 바꿨을 때 사용하세요.', '학년 칩을 눌러 학년을 바꾸고, 기기 리셋은 학생이 폰을 바꿨을 때 사용하세요.',
style: TextStyle( style: TextStyle(color: Colors.black54, fontSize: 12),
color: Colors.black54,
fontSize: 12,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_controller.isLoadingStudents _controller.isLoadingStudents
? const Padding( ? const Padding(
padding: EdgeInsets.symmetric(vertical: 40), padding: EdgeInsets.symmetric(vertical: 40),
child: Center( child: Center(child: CircularProgressIndicator()),
child: CircularProgressIndicator(),
),
) )
: _controller.students.isEmpty : _controller.students.isEmpty
? const Padding( ? const Padding(
@@ -770,30 +639,98 @@ class _TeacherStudentManagementPageState
), ),
), ),
) )
: GridView.builder( : ListView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent:
kAttendanceTileWidth,
mainAxisExtent: kAttendanceTileHeight,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _controller.students.length, itemCount: _controller.students.length,
itemBuilder: (context, index) => itemBuilder: (context, index) {
_buildStudentTile( final student = _controller.students[index];
_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_core
import firebase_messaging import firebase_messaging
import flutter_local_notifications import flutter_local_notifications
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
@@ -20,5 +19,4 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) 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" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.1" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -480,30 +472,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: permission_handler:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -576,62 +544,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -791,4 +703,4 @@ packages:
version: "6.6.1" version: "6.6.1"
sdks: sdks:
dart: ">=3.12.2 <4.0.0" 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 # 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 # 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. # 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: environment:
sdk: ^3.12.2 sdk: ^3.12.2
@@ -41,7 +41,6 @@ dependencies:
nfc_manager: ^4.2.1 nfc_manager: ^4.2.1
nfc_manager_ndef: ^1.1.0 nfc_manager_ndef: ^1.1.0
http: ^1.1.0 http: ^1.1.0
shared_preferences: ^2.3.3
# 🔦 주머니 제출 모드 무단 반출 감지용 조도 센서 (Android 전용, iOS는 SensorKit 엔타이틀먼트 필요) # 🔦 주머니 제출 모드 무단 반출 감지용 조도 센서 (Android 전용, iOS는 SensorKit 엔타이틀먼트 필요)
light: ^5.0.0 light: ^5.0.0
@@ -57,7 +56,6 @@ dependencies:
file_picker: ^12.1.2 file_picker: ^12.1.2
desktop_drop: ^0.8.3 desktop_drop: ^0.8.3
web: ^1.1.1 web: ^1.1.1
flutter_staggered_grid_view: ^0.7.0
dev_dependencies: dev_dependencies:
flutter_test: 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 charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project."> <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 --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
@@ -35,22 +31,6 @@
<title>school_attendance</title> <title>school_attendance</title>
<link rel="manifest" href="manifest.json"> <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> </head>
<body> <body>
<!-- <!--
+2 -2
View File
@@ -3,8 +3,8 @@
"short_name": "school_attendance", "short_name": "school_attendance",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#EEF0F2", "background_color": "#0175C2",
"theme_color": "#1C1C1C", "theme_color": "#0175C2",
"description": "A new Flutter project.", "description": "A new Flutter project.",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"prefer_related_applications": false, "prefer_related_applications": false,