Files
school-attendance/lib/pocket_watch_service.dart
T

173 lines
6.0 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으로 학생에게 전달됨)
}
}
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();
reportViolation();
}
}
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();
});
}