Files
school-attendance/lib/ui/nfc_poccket_checkin_screen.dart
sihooandClaude Sonnet 5 5d37d3e3b1 제목 알약 폰트 통일 + 위치 조정, 좁은 폼 너비, 메인 타일 확대
- TitlePill을 String 기반으로 바꿔서 모든 화면(설정/교사 회원가입/관리자
  시스템/NFC 태그 쓰기·체크인/학생 관리/스마트기기 반출 신청·대장/실시간
  출석 현황)이 완전히 같은 폰트(굵게, 15px, 흰색)를 쓰게 통일
- 알약이 화면 맨 위에 딱 붙어 보이지 않도록 위쪽에 8px 여백 추가
- "스마트기기 반출 신청" 폼과 "관리자 시스템"의 "모든 출석 데이터 초기화"
  버튼이 넓은 화면에서 양옆으로 과하게 늘어지던 문제 수정 - 최대 폭
  420px로 제한하고 가운데 정렬 (기존 대비 약 1/3 크기)
- 메인 대시보드 타일 목표 크기를 184 → 276(50% 확대)로 키우고, 아이콘/
  패딩/폰트 기준값도 함께 올려서 제목 10→12pt, 부제 8→10pt로 확대

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:26:29 +09:00

274 lines
9.8 KiB
Dart

// 📲 자습실 NFC 출석체크 화면 (UI 전용). 태깅 상태 화면/팝업/스낵바만 그린다.
// NFC 세션, 서버 통신, 백그라운드 감시 연동은
// lib/function/nfc_pocket_checkin_controller.dart가 담당한다.
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../function/nfc_pocket_checkin_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
class NfcPocketCheckInScreen extends StatefulWidget {
final String studentId; // 로그인된 학생 학번 (예: "2061")
final String studentName; // 로그인된 학생 이름 (예: "시후")
const NfcPocketCheckInScreen({
super.key,
required this.studentId,
required this.studentName,
});
@override
State<NfcPocketCheckInScreen> createState() => _NfcPocketCheckInScreenState();
}
class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
late final NfcPocketCheckinController _controller;
bool _isDialogOpen = false; // 출석/위반 팝업이 겹쳐서 뜨는 것을 막기 위한 플래그
@override
void initState() {
super.initState();
_controller = NfcPocketCheckinController(
studentId: widget.studentId,
studentName: widget.studentName,
onMessage: _handleMessage,
onCheckInSuccess: _showSuccessDialog,
onViolationDetected: _showViolationDialog,
onNeedGuidedAccessSetup: _showGuidedAccessDialog,
);
_controller.init();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleMessage(String text, WatchMessageLevel level) {
final Color color = switch (level) {
WatchMessageLevel.info => Colors.blueGrey,
WatchMessageLevel.success => Colors.green,
WatchMessageLevel.warning => Colors.orange,
WatchMessageLevel.error => Colors.red,
};
_showSnackBar(text, color);
}
/// 이미 떠 있는 팝업(출석 완료/무단반출 감지)이 있으면 새 팝업을 띄우기 전에 먼저 닫는다.
/// (태깅→반출→재태깅이 빠르게 반복되면 팝업이 여러 개 겹쳐 쌓이는 것을 방지)
void _closeAnyOpenDialog() {
if (_isDialogOpen && mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
_isDialogOpen = false;
}
void _showViolationDialog(String? pocketNumber) {
if (!mounted) return;
_closeAnyOpenDialog();
_isDialogOpen = true;
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.red[50],
title: const Text(
"무단 반출 감지",
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
content: Text(
"[${pocketNumber ?? _controller.activePocketNumber}] 주머니에서 휴대폰이 꺼내진 것으로 감지되었습니다.\n담당 선생님께 알림이 전송되었습니다.",
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("확인"),
),
],
),
).then((_) => _isDialogOpen = false);
}
/// 🛡️ iOS 전용: 근접센서 감시가 화면 잠금으로 끊기지 않도록, "가이드 접근"을
/// 직접 켜달라고 안내하는 팝업. 학생이 켰다고 확인해야 실제 감시가 시작된다.
void _showGuidedAccessDialog(String pocketNumber) {
if (!mounted) return;
_closeAnyOpenDialog();
_isDialogOpen = true;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text("감시 모드 켜기 전에"),
content: const Text(
"iOS는 화면을 잠그면 감시가 끊겨요. 아래 순서로 '가이드 접근'을 먼저 켜주세요.\n\n"
"1. 사이드(또는 홈) 버튼 3번 빠르게 누르기\n"
"2. 목록에서 '가이드 접근' 선택 → 화면 오른쪽 아래 '시작' 누르기\n\n"
"다 켜셨으면 아래 버튼을 눌러 감시를 시작하세요.\n"
"(감시를 끝낼 땐 먼저 사이드 버튼 3번으로 가이드 접근을 끈 다음 다시 태깅하세요.)",
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
_controller.beginIosProximityWatch(pocketNumber);
},
child: const Text("가이드 접근 켰어요"),
),
],
),
).then((_) => _isDialogOpen = false);
}
/// 🎊 출석 성공 알림창
void _showSuccessDialog(String pocketNumber) {
if (!mounted) return;
_closeAnyOpenDialog();
_isDialogOpen = true;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("자습실 출석 완료!"),
content: Text(
"${widget.studentName} 학생!\n[$pocketNumber] 주머니 출석이 확인되었습니다.\n\n폰을 주머니에 쏙 넣고 자습에 집중해 주세요!",
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("확인"),
),
],
),
).then((_) => _isDialogOpen = false);
}
void _showSnackBar(String text, Color color) {
if (!mounted) return;
AppNotice.show(context, text, color: color);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
if (_controller.isWatching) {
return _buildPocketWatchScreen();
}
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill("자습실 NFC 출석체크"),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// NFC 애니메이션 아이콘
Icon(
_controller.isProcessing ? Icons.sync : Icons.nfc,
size: 100,
color: _controller.isProcessing
? Colors.orange
: Colors.blueAccent,
),
const SizedBox(height: 30),
// 상태 메시지 표시
Text(
_controller.statusMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _controller.isProcessing
? Colors.orange
: Colors.black87,
),
),
const SizedBox(height: 15),
const Text(
"자기 주머니 번호 숫자에 폰 뒷면을 '톡' 대면\n자동으로 출석 처리됩니다.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 14),
),
],
),
),
),
);
},
);
}
/// 💳 삼성페이 결제창 느낌의 전체화면 "주머니 제출 모드" 안내 UI
/// (실제 감시 로직은 pocket_watch_service.dart의 백그라운드 서비스가 담당하므로,
/// 이 화면은 상태를 보여주고 화면을 꺼도 된다는 것만 안내한다.)
Widget _buildPocketWatchScreen() {
return Scaffold(
backgroundColor: const Color(0xFF0B1120),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 160,
height: 160,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.greenAccent.withValues(alpha: 0.15),
border: Border.all(color: Colors.greenAccent, width: 3),
),
child: const Icon(
Icons.shield_rounded,
size: 72,
color: Colors.greenAccent,
),
),
const SizedBox(height: 40),
Text(
_controller.activePocketNumber ?? "",
style: const TextStyle(
color: Colors.white54,
fontSize: 14,
letterSpacing: 2,
),
),
const SizedBox(height: 12),
Text(
_controller.statusMessage,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
height: 1.4,
),
),
const SizedBox(height: 8),
Text(
!kIsWeb && Platform.isIOS
? "가이드 접근을 켠 채로 화면만 잠기도록 두세요.\n(임의로 앱을 나가면 감시가 끊깁니다)\n감시를 끝내려면 가이드 접근을 먼저 끄고 다시 태깅하세요."
: "화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)",
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white38, fontSize: 13),
),
],
),
),
),
);
}
}