Files
school-attendance/lib/pocket_watch_service.dart
T
sihooandClaude Sonnet 5 9089b9032e UI 문구 이모티콘 제거 + 계정 강제 삭제 아이콘 검정으로 통일
화면 타이틀, 스낵바/다이얼로그 메시지, 백그라운드 알림 문구 등
사용자에게 노출되는 텍스트에서 이모티콘을 전부 제거 (코드 주석은
대상 아님). 아이콘 위젯은 그대로 유지.

main_dashboard.dart의 "계정 강제 삭제" 타일 아이콘 색상도 빨간색
대신 다른 타일과 동일한 검정(AppPalette.ink)으로 통일.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:31:03 +09:00

197 lines
6.8 KiB
Dart

// 🛡️ 화면이 꺼져도 동작하는 포그라운드 백그라운드 서비스.
// 조도 센서로 주머니 속 밝기를 측정해 무단 반출을 감지하고, 진동+알림+서버 신고를 처리한다.
import 'dart:async';
import 'dart:convert';
import 'dart:ui';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:http/http.dart' as http;
import 'package:light/light.dart';
import 'package:vibration/vibration.dart';
import 'config.dart' show baseUrl;
// 🔦 조도 센서 판정 관련 튜닝 값 (nfc_poccket_checkin_screen.dart와 동일한 기준)
const int _calibrationSampleCount = 3;
const Duration _placePhoneDelay = Duration(seconds: 4);
const int _luxJumpThreshold = 40;
const String pocketWatchNotificationChannelId = 'pocket_watch_channel';
const int pocketWatchNotificationId = 9001;
final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
/// 앱 시작(main()) 시 한 번만 호출. 알림 채널 생성 + 백그라운드 서비스 설정.
Future<void> initializePocketWatchService() async {
const AndroidNotificationChannel channel = AndroidNotificationChannel(
pocketWatchNotificationChannelId,
'주머니 감시 서비스',
description: '휴대폰 무단 반출 감시 중 상태를 표시합니다.',
importance: Importance.low, // 조용한 알림 (소리/진동 없음)
);
await _localNotifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(channel);
final service = FlutterBackgroundService();
await service.configure(
androidConfiguration: AndroidConfiguration(
onStart: onPocketWatchServiceStart,
autoStart: false, // NFC 태깅으로 출석 성공했을 때만 수동으로 시작
autoStartOnBoot: false,
isForegroundMode: true,
notificationChannelId: pocketWatchNotificationChannelId,
initialNotificationTitle: '주머니 감시 대기 중',
initialNotificationContent: '출석 태깅을 기다리는 중입니다.',
foregroundServiceNotificationId: pocketWatchNotificationId,
foregroundServiceTypes: [AndroidForegroundType.specialUse],
),
iosConfiguration: IosConfiguration(autoStart: false),
);
}
@pragma('vm:entry-point')
void onPocketWatchServiceStart(ServiceInstance service) {
DartPluginRegistrant.ensureInitialized();
StreamSubscription<int>? lightSub;
final List<int> calibrationSamples = [];
int? baselineLux;
String? studentId;
String? studentName;
String? pocketNumber;
bool violated = false;
void updateNotification(String title, String content) {
if (service is AndroidServiceInstance) {
_localNotifications.show(
id: pocketWatchNotificationId,
title: title,
body: content,
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
pocketWatchNotificationChannelId,
'주머니 감시 서비스',
icon: 'ic_bg_service_small',
ongoing: true,
importance: Importance.low,
priority: Priority.low,
),
),
);
}
}
Future<void> reportViolation() async {
// 진동 3연타로 강하게 경고 (백그라운드 서비스에서는 HapticFeedback이 아닌 vibration 패키지를 써야 동작함)
Vibration.vibrate(pattern: [0, 400, 150, 400, 150, 400]);
updateNotification("무단 반출 감지!", "[$pocketNumber] 주머니에서 폰이 감지되지 않습니다.");
service.invoke('violation_detected', {
"pocketNumber": pocketNumber,
"timestamp": DateTime.now().toIso8601String(),
});
try {
await http.post(
Uri.parse("$baseUrl/attendance/violation"),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"studentId": studentId,
"studentName": studentName,
"pocketNumber": pocketNumber,
"type": "PHONE_REMOVED",
"timestamp": DateTime.now().toIso8601String(),
}),
);
} catch (_) {
// 백그라운드 서비스에서는 UI로 에러를 띄울 수 없으니 조용히 무시.
// (기록 자체는 이미 로컬 알림+haptic으로 학생에게 전달됨)
}
}
/// 하교 처리/반출 허용 시간대라면 조용히 감시만 종료하고, 아니면 위반으로 경고한다.
Future<void> handlePhoneRemoved() async {
bool isPermitted = false;
try {
final res = await http.get(
Uri.parse("$baseUrl/api/permissions/check?studentId=$studentId"),
);
if (res.statusCode == 200) {
final data = jsonDecode(utf8.decode(res.bodyBytes));
isPermitted = data['permitted'] == true;
}
} catch (_) {
isPermitted = false; // 네트워크 오류 시엔 안전하게 위반으로 처리
}
if (isPermitted) {
updateNotification("폰 회수 완료", "[$pocketNumber] 주머니에서 정상적으로 회수되었습니다.");
service.invoke('checked_out', {"pocketNumber": pocketNumber});
service.stopSelf();
} else {
reportViolation();
}
}
void onLightReading(int luxValue) {
if (violated) return;
if (baselineLux == null) {
calibrationSamples.add(luxValue);
if (calibrationSamples.length >= _calibrationSampleCount) {
baselineLux =
calibrationSamples.reduce((a, b) => a + b) ~/
calibrationSamples.length;
updateNotification("감시 중", "[$pocketNumber] 폰이 주머니 안에 있는지 감시 중");
}
return;
}
if (luxValue > baselineLux! + _luxJumpThreshold) {
violated = true;
lightSub?.cancel();
handlePhoneRemoved();
}
}
Future<void> startWatching() async {
lightSub?.cancel();
calibrationSamples.clear();
baselineLux = null;
violated = false;
updateNotification("주머니에 넣는 중...", "잠시 후 감시가 시작됩니다.");
await Future.delayed(_placePhoneDelay);
updateNotification("밝기 측정 중...", "주머니 속 밝기를 기준값으로 설정하는 중입니다.");
try {
await Light().requestAuthorization();
lightSub = Light().lightSensorStream.listen(
onLightReading,
onError: (Object error) {
updateNotification("조도 센서 오류", "$error");
},
);
} catch (e) {
updateNotification("조도 센서 시작 실패", "$e");
}
}
service.on('start_watch').listen((event) {
studentId = event?['studentId'];
studentName = event?['studentName'];
pocketNumber = event?['pocketNumber'];
startWatching();
});
service.on('stop_watch').listen((event) {
lightSub?.cancel();
service.stopSelf();
});
}