From a66109885583690154c9e2ee9e1aec8add2ea992 Mon Sep 17 00:00:00 2001 From: b3ni15 Date: Tue, 3 Mar 2026 11:41:47 +0100 Subject: [PATCH] Implement live activity token rotation and server synchronization for push notifications --- refilc/ios/Runner/AppDelegate.swift | 240 ++++++++++-------- refilc/ios/Runner/public_vars.swift | 1 + refilc/ios/livecard/LiveActivityManager.swift | 164 ++++++++---- .../lib/api/providers/live_card_provider.dart | 35 ++- .../liveactivity/platform_channel.dart | 43 +++- .../liveactivity/server_sync_provider.dart | 134 ++++++++++ 6 files changed, 461 insertions(+), 156 deletions(-) create mode 100644 refilc/lib/api/providers/liveactivity/server_sync_provider.dart diff --git a/refilc/ios/Runner/AppDelegate.swift b/refilc/ios/Runner/AppDelegate.swift index d7697333..73e28abe 100644 --- a/refilc/ios/Runner/AppDelegate.swift +++ b/refilc/ios/Runner/AppDelegate.swift @@ -2,112 +2,152 @@ import UIKit import background_fetch import ActivityKit import Flutter +import Security @main @objc class AppDelegate: FlutterAppDelegate { - private var methodChannel: FlutterMethodChannel? + private var methodChannel: FlutterMethodChannel? + private var tokenRotationObserver: NSObjectProtocol? - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - guard let controller = window?.rootViewController as? FlutterViewController else { - fatalError("rootViewController is not type FlutterViewController") - } - methodChannel = FlutterMethodChannel(name: "app.firka/liveactivity", - binaryMessenger: controller as! FlutterBinaryMessenger) - methodChannel?.setMethodCallHandler({ - [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in - guard call.method == "createLiveActivity" || call.method == "endLiveActivity" || call.method == "updateLiveActivity" else { - result(FlutterMethodNotImplemented) - return - } - self?.handleMethodCall(call, result: result) - }) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } - - override func applicationWillTerminate(_ application: UIApplication) { - if #available(iOS 16.2, *) { - LiveActivityManager.stop() - } - } - - private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - if call.method == "createLiveActivity" { - if let args = call.arguments as? [String: Any] { - lessonDataDictionary = args - globalLessonData = LessonData(from: lessonDataDictionary) - print("swift: megkapott flutter adatok:",lessonDataDictionary) - print("Live Activity bekapcsolva az eszközön: ",checkLiveActivityFeatureAvailable()) - if(checkLiveActivityFeatureAvailable()) { - createLiveActivity(with: lessonDataDictionary) - result(checkLiveActivityFeatureAvailable()) - } else { - result(nil) - } - - } else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid iOS arguments received", details: nil)) - } - } else if call.method == "updateLiveActivity" { - if let args = call.arguments as? [String: Any] { - lessonDataDictionary = args - globalLessonData = LessonData(from: lessonDataDictionary) - updateLiveActivity(with: lessonDataDictionary) - result(nil) - } else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid iOS arguments received", details: nil)) + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + guard let controller = window?.rootViewController as? FlutterViewController else { + fatalError("rootViewController is not type FlutterViewController") } - } else if call.method == "endLiveActivity" { - endLiveActivity() - result(nil) + methodChannel = FlutterMethodChannel( + name: "app.firka/liveactivity", + binaryMessenger: controller as! FlutterBinaryMessenger + ) + methodChannel?.setMethodCallHandler({ [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + guard call.method == "createLiveActivity" + || call.method == "endLiveActivity" + || call.method == "updateLiveActivity" + else { + result(FlutterMethodNotImplemented) + return + } + self?.handleMethodCall(call, result: result) + }) + + // Token rotation figyelése: ha az APNs új tokent ad, értesítjük Flutter-t + tokenRotationObserver = NotificationCenter.default.addObserver( + forName: NSNotification.Name("LiveActivityTokenUpdated"), + object: nil, + queue: .main + ) { [weak self] notification in + if let newToken = notification.object as? String { + let deviceId = self?.getOrCreateDeviceId() ?? "" + let bundleId = Bundle.main.bundleIdentifier ?? "" + self?.methodChannel?.invokeMethod("liveActivityTokenUpdated", arguments: [ + "pushToken": newToken, + "deviceId": deviceId, + "bundleId": bundleId, + ]) + } + } + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - } - - private func createLiveActivity(with activityData: [String: Any]) -> String? { - var lessonData = LessonData(from: activityData) - print("Live Activity létrehozása...") - if #available(iOS 16.2, *) { - LiveActivityManager.create() - } - return nil - } - private func updateLiveActivity(with activityData: [String: Any]) { - let lessonData = LessonData(from: activityData) - print("swift: megkapott flutter adatok:",lessonDataDictionary) - print("Live Activity frissítés...") - if #available(iOS 16.2, *) { - LiveActivityManager.update() - } - } + override func applicationWillTerminate(_ application: UIApplication) { + if #available(iOS 16.2, *) { + LiveActivityManager.stop() + } + if let observer = tokenRotationObserver { + NotificationCenter.default.removeObserver(observer) + } + } - - private func endLiveActivity() { - print("Live Activity befejezése...") - if #available(iOS 16.2, *) { - LiveActivityManager.stop() - } - } - - private func checkIfLiveActivityExists() -> Bool { - if let activityID = activityID { - if #available(iOS 16.2, *) { - return LiveActivityManager.isRunning(activityID) - } - } - return false - } - - private func checkLiveActivityFeatureAvailable() -> Bool { - if #available(iOS 16.2, *) { - guard ActivityAuthorizationInfo().areActivitiesEnabled else { - return false - } - return true - } - return false - } + private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + if call.method == "createLiveActivity" { + guard let args = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid iOS arguments received", details: nil)) + return + } + lessonDataDictionary = args + globalLessonData = LessonData(from: lessonDataDictionary) + print("swift: megkapott flutter adatok:", lessonDataDictionary) + print("Live Activity bekapcsolva az eszközön:", checkLiveActivityFeatureAvailable()) + + guard checkLiveActivityFeatureAvailable() else { + result(nil) + return + } + + if #available(iOS 16.2, *) { + LiveActivityManager.create { [weak self] pushToken in + guard let self = self, let token = pushToken else { + result(nil) + return + } + let deviceId = self.getOrCreateDeviceId() + let bundleId = Bundle.main.bundleIdentifier ?? "" + result([ + "pushToken": token, + "deviceId": deviceId, + "bundleId": bundleId, + ]) + } + } else { + result(nil) + } + + } else if call.method == "updateLiveActivity" { + guard let args = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid iOS arguments received", details: nil)) + return + } + lessonDataDictionary = args + globalLessonData = LessonData(from: lessonDataDictionary) + if #available(iOS 16.2, *) { + LiveActivityManager.update() + } + result(nil) + + } else if call.method == "endLiveActivity" { + if #available(iOS 16.2, *) { + LiveActivityManager.stop() + } + result(nil) + } + } + + // MARK: - Keychain device ID + + private func getOrCreateDeviceId() -> String { + let keychainKey = "refilc.live.device_id" + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keychainKey, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, + let existingId = String(data: data, encoding: .utf8) { + return existingId + } + // Nem létezik – generálunk egyet + let newId = UUID().uuidString + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keychainKey, + kSecValueData as String: newId.data(using: .utf8)!, + ] + SecItemAdd(addQuery as CFDictionary, nil) + return newId + } + + // MARK: - Helpers + + private func checkLiveActivityFeatureAvailable() -> Bool { + if #available(iOS 16.2, *) { + return ActivityAuthorizationInfo().areActivitiesEnabled + } + return false + } } diff --git a/refilc/ios/Runner/public_vars.swift b/refilc/ios/Runner/public_vars.swift index f084d22d..722cc4a5 100644 --- a/refilc/ios/Runner/public_vars.swift +++ b/refilc/ios/Runner/public_vars.swift @@ -10,3 +10,4 @@ import Foundation var lessonDataDictionary: [String: Any] = [:] var globalLessonData = LessonData(from: lessonDataDictionary) var activityID: String? = "" +var activityPushToken: String? = nil diff --git a/refilc/ios/livecard/LiveActivityManager.swift b/refilc/ios/livecard/LiveActivityManager.swift index 25f9eabf..61667fb9 100644 --- a/refilc/ios/livecard/LiveActivityManager.swift +++ b/refilc/ios/livecard/LiveActivityManager.swift @@ -17,7 +17,7 @@ public struct LiveActivitiesAppAttributes: ActivityAttributes, Identifiable { var nextSubject: String var nextRoom: String } - + public var id = UUID() } @@ -26,67 +26,141 @@ final class LiveActivityManager { static let shared = LiveActivityManager() var currentActivity: Activity? - class func create() { - - Task { + /// Létrehozza a Live Activity-t pushType: .token-nel, majd visszaadja az APNs push tokent. + class func create(completion: @escaping (String?) -> Void) { + Task { + do { + let contentState = LiveActivitiesAppAttributes.ContentState( + color: globalLessonData.color, + icon: globalLessonData.icon, + index: globalLessonData.index, + title: globalLessonData.title, + subtitle: globalLessonData.subtitle, + description: globalLessonData.description, + startDate: globalLessonData.startDate, + endDate: globalLessonData.endDate, + date: globalLessonData.date, + nextSubject: globalLessonData.nextSubject, + nextRoom: globalLessonData.nextRoom + ) + + let activityContent = ActivityContent( + state: contentState, + staleDate: globalLessonData.endDate, + relevanceScore: 0 + ) + + let activity = try Activity.request( + attributes: LiveActivitiesAppAttributes(), + content: activityContent, + pushType: .token + ) + + activityID = activity.id + print("Live Activity létrehozva. Azonosító: \(activity.id)") + + // Az APNs token aszinkron érkezik – megvárjuk az elsőt + for await tokenData in activity.pushTokenUpdates { + let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined() + activityPushToken = tokenHex + print("Live Activity push token: \(tokenHex)") + completion(tokenHex) + // Token rotation figyelése a háttérben + Task { await monitorTokenRotation(activity: activity) } + break + } + } catch { + print("Hiba történt a Live Activity létrehozásakor: \(error)") + completion(nil) + } + } + } + + /// Token rotation figyelése: ha az APNs új tokent ad ki, értesítjük a Flutter oldalt. + private class func monitorTokenRotation(activity: Activity) async { + var isFirst = true + for await tokenData in activity.pushTokenUpdates { + if isFirst { isFirst = false; continue } // Az első tokent már kezeltük + let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined() + activityPushToken = tokenHex + print("Live Activity push token frissítve (rotation): \(tokenHex)") + NotificationCenter.default.post( + name: NSNotification.Name("LiveActivityTokenUpdated"), + object: tokenHex + ) + } + } + + class func update() { + Task { + for activity in Activity.activities { do { - let contentState = LiveActivitiesAppAttributes.ContentState(color: globalLessonData.color, icon: globalLessonData.icon, index: globalLessonData.index, title: globalLessonData.title, subtitle: globalLessonData.subtitle, description: globalLessonData.description, startDate: globalLessonData.startDate, endDate: globalLessonData.endDate, date: globalLessonData.date, nextSubject: globalLessonData.nextSubject, nextRoom: globalLessonData.nextRoom) - - let activityContent = ActivityContent(state: contentState, staleDate: globalLessonData.endDate, relevanceScore: 0) - - let activity = try Activity.request( - attributes: LiveActivitiesAppAttributes(), - content: activityContent, - pushType: nil + let contentState = LiveActivitiesAppAttributes.ContentState( + color: globalLessonData.color, + icon: globalLessonData.icon, + index: globalLessonData.index, + title: globalLessonData.title, + subtitle: globalLessonData.subtitle, + description: globalLessonData.description, + startDate: globalLessonData.startDate, + endDate: globalLessonData.endDate, + date: globalLessonData.date, + nextSubject: globalLessonData.nextSubject, + nextRoom: globalLessonData.nextRoom ) - + + let activityContent = ActivityContent( + state: contentState, + staleDate: globalLessonData.endDate, + relevanceScore: 0 + ) + + await activity.update(activityContent) activityID = activity.id - print("Live Activity létrehozva. Azonosító: \(activity.id)") + print("Live Activity frissítve. Azonosító: \(activity.id)") } catch { - print("Hiba történt a Live Activity létrehozásakor: \(error)") + print("Hiba történt a Live Activity frissítésekor: \(error)") } } - } - - class func update() { - Task { - for activity in Activity.activities { - do { - let contentState = LiveActivitiesAppAttributes.ContentState(color: globalLessonData.color, icon: globalLessonData.icon, index: globalLessonData.index, title: globalLessonData.title, subtitle: globalLessonData.subtitle, description: globalLessonData.description, startDate: globalLessonData.startDate, endDate: globalLessonData.endDate, date: globalLessonData.date, nextSubject: globalLessonData.nextSubject, nextRoom: globalLessonData.nextRoom) - - let activityContent = ActivityContent(state: contentState, staleDate: globalLessonData.endDate, relevanceScore: 0) - - await activity.update(activityContent) - activityID = activity.id - print("Live Activity frissítve. Azonosító: \(activity.id)") - } catch { - print("Hiba történt a Live Activity frissítésekor: \(error)") - } - } - } } - - + } + class func stop() { - if (activityID != "") { + if activityID != "" { Task { - for activity in Activity.activities{ - let contentState = LiveActivitiesAppAttributes.ContentState(color: globalLessonData.color, icon: globalLessonData.icon, index: globalLessonData.index, title: globalLessonData.title, subtitle: globalLessonData.subtitle, description: globalLessonData.description, startDate: globalLessonData.startDate, endDate: globalLessonData.endDate, date: globalLessonData.date, nextSubject: globalLessonData.nextSubject, nextRoom: globalLessonData.nextRoom) - - await activity.end(ActivityContent(state: contentState, staleDate: Date.distantFuture),dismissalPolicy: .immediate) + for activity in Activity.activities { + let contentState = LiveActivitiesAppAttributes.ContentState( + color: globalLessonData.color, + icon: globalLessonData.icon, + index: globalLessonData.index, + title: globalLessonData.title, + subtitle: globalLessonData.subtitle, + description: globalLessonData.description, + startDate: globalLessonData.startDate, + endDate: globalLessonData.endDate, + date: globalLessonData.date, + nextSubject: globalLessonData.nextSubject, + nextRoom: globalLessonData.nextRoom + ) + + await activity.end( + ActivityContent(state: contentState, staleDate: Date.distantFuture), + dismissalPolicy: .immediate + ) } activityID = nil + activityPushToken = nil print("Live Activity sikeresen leállítva") } } } class func isRunning(_ activityID: String) -> Bool { - for activity in Activity.activities { - if activity.id == activityID { - return true - } + for activity in Activity.activities { + if activity.id == activityID { + return true } - return false } + return false + } } diff --git a/refilc/lib/api/providers/live_card_provider.dart b/refilc/lib/api/providers/live_card_provider.dart index 8b50a74e..a8829dd5 100644 --- a/refilc/lib/api/providers/live_card_provider.dart +++ b/refilc/lib/api/providers/live_card_provider.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:refilc/api/providers/liveactivity/platform_channel.dart'; +import 'package:refilc/api/providers/liveactivity/server_sync_provider.dart'; import 'package:refilc/helpers/subject.dart'; import 'package:refilc/models/settings.dart'; import 'package:refilc/ui/flutter_colorpicker/utils.dart'; @@ -45,6 +46,7 @@ class LiveCardProvider extends ChangeNotifier { late Timer _timer; late final TimetableProvider _timetable; late final SettingsProvider _settings; + final ServerSyncProvider _serverSync = ServerSyncProvider(); late Duration _delay; @@ -59,6 +61,16 @@ class LiveCardProvider extends ChangeNotifier { _delay = settings.bellDelayEnabled ? Duration(seconds: settings.bellDelay) : Duration.zero; + + // Token rotation figyelése: ha iOS új APNs tokent ad ki, szinkronizáljuk a szerverrel + PlatformChannel.onTokenUpdated = (pushToken, deviceId, bundleId) { + _serverSync.refreshToken( + pushToken: pushToken, + bundleId: bundleId, + liveActivityColor: '#${settings.liveActivityColor.toHexString().substring(2)}', + ); + }; + update(); } @@ -325,15 +337,15 @@ class LiveCardProvider extends ChangeNotifier { currentState == LiveCardState.night)) { debugPrint( "Az első óra előtt állunk, kevesebb mint egy órával. Létrehozás..."); - PlatformChannel.createLiveActivity(toMap()); hasActivityStarted = true; + _createAndSync(); } else if (!hasActivityStarted && ((currentState == LiveCardState.duringLesson && currentLesson != null) || currentState == LiveCardState.duringBreak)) { debugPrint("Óra van, vagy szünet, de nincs LiveActivity. létrehozás..."); - PlatformChannel.createLiveActivity(toMap()); hasActivityStarted = true; + _createAndSync(); } //UPDATE @@ -385,6 +397,25 @@ class LiveCardProvider extends ChangeNotifier { notifyListeners(); } + Future _createAndSync() async { + final result = await PlatformChannel.createLiveActivity(toMap()); + if (result != null) { + final pushToken = result['pushToken'] ?? ''; + final deviceId = result['deviceId'] ?? ''; + final bundleId = result['bundleId'] ?? ''; + if (pushToken.isNotEmpty && deviceId.isNotEmpty) { + await _serverSync.registerAndSync( + deviceId: deviceId, + pushToken: pushToken, + bundleId: bundleId, + liveActivityColor: + '#${_settings.liveActivityColor.toHexString().substring(2)}', + todayLessons: _today(_timetable), + ); + } + } + } + bool get show => currentState != LiveCardState.empty; Duration get delay => _delay; diff --git a/refilc/lib/api/providers/liveactivity/platform_channel.dart b/refilc/lib/api/providers/liveactivity/platform_channel.dart index c6f7ba2f..24ef1901 100644 --- a/refilc/lib/api/providers/liveactivity/platform_channel.dart +++ b/refilc/lib/api/providers/liveactivity/platform_channel.dart @@ -6,16 +6,41 @@ import 'package:flutter/services.dart'; class PlatformChannel { static const MethodChannel _channel = MethodChannel('app.firka/liveactivity'); - /// Létrehozza a Live Activity-t és visszaadja az APNs push tokent (ha elérhető). - static Future createLiveActivity( + /// Callback token rotation esetén (iOS APNs új tokent ad ki). + /// A szerver szinkronizálásért felelős kód regisztrálhat ide. + static void Function(String pushToken, String deviceId, String bundleId)? onTokenUpdated; + + static void _setupTokenRotationListener() { + _channel.setMethodCallHandler((call) async { + if (call.method == 'liveActivityTokenUpdated') { + final args = call.arguments as Map?; + if (args != null && onTokenUpdated != null) { + onTokenUpdated!( + args['pushToken'] as String? ?? '', + args['deviceId'] as String? ?? '', + args['bundleId'] as String? ?? '', + ); + } + } + }); + } + + /// Létrehozza a Live Activity-t és visszaadja az APNs push tokent, + /// a device ID-t (Keychain UUID) és a bundle ID-t. + static Future?> createLiveActivity( Map activityData) async { if (Platform.isIOS) { + _setupTokenRotationListener(); try { - debugPrint("creating..."); - final String? pushToken = await _channel.invokeMethod( + debugPrint("creating live activity..."); + final result = await _channel.invokeMethod( 'createLiveActivity', activityData); - debugPrint("Live Activity push token: $pushToken"); - return pushToken; + if (result == null) return null; + return { + 'pushToken': result['pushToken'] as String? ?? '', + 'deviceId': result['deviceId'] as String? ?? '', + 'bundleId': result['bundleId'] as String? ?? '', + }; } on PlatformException catch (e) { debugPrint("Hiba történt a Live Activity létrehozásakor: ${e.message}"); } @@ -27,7 +52,7 @@ class PlatformChannel { Map activityData) async { if (Platform.isIOS) { try { - debugPrint("updating..."); + debugPrint("updating live activity..."); await _channel.invokeMethod('updateLiveActivity', activityData); } on PlatformException catch (e) { debugPrint("Hiba történt a Live Activity frissítésekor: ${e.message}"); @@ -38,11 +63,11 @@ class PlatformChannel { static Future endLiveActivity() async { if (Platform.isIOS) { try { - debugPrint("finishing..."); + debugPrint("ending live activity..."); await _channel.invokeMethod('endLiveActivity'); } on PlatformException catch (e) { debugPrint("Hiba történt a Live Activity befejezésekor: ${e.message}"); } } } -} \ No newline at end of file +} diff --git a/refilc/lib/api/providers/liveactivity/server_sync_provider.dart b/refilc/lib/api/providers/liveactivity/server_sync_provider.dart new file mode 100644 index 00000000..83440a96 --- /dev/null +++ b/refilc/lib/api/providers/liveactivity/server_sync_provider.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:refilc/helpers/subject.dart'; +import 'package:refilc/utils/format.dart'; +import 'package:refilc_kreta_api/models/lesson.dart'; + +class ServerSyncProvider { + static const String _baseUrl = 'https://live.firka.app'; + + String? _deviceId; + + /// App indításkor hívandó: regisztrálja a device-t és feltölti a mai órarendet. + Future registerAndSync({ + required String deviceId, + required String pushToken, + required String bundleId, + required String liveActivityColor, + required List todayLessons, + }) async { + _deviceId = deviceId; + + await _register( + deviceId: deviceId, + pushToken: pushToken, + bundleId: bundleId, + liveActivityColor: liveActivityColor, + ); + + final validLessons = _filterLessons(todayLessons); + if (validLessons.isNotEmpty) { + await _uploadSchedule(deviceId: deviceId, lessons: validLessons); + } + } + + /// Token rotation esetén frissíti a tokent a szerveren. + Future refreshToken({ + required String pushToken, + required String bundleId, + required String liveActivityColor, + }) async { + if (_deviceId == null) return; + await _register( + deviceId: _deviceId!, + pushToken: pushToken, + bundleId: bundleId, + liveActivityColor: liveActivityColor, + ); + } + + Future _register({ + required String deviceId, + required String pushToken, + required String bundleId, + required String liveActivityColor, + }) async { + try { + final client = HttpClient(); + final uri = Uri.parse('$_baseUrl/register'); + final request = await client.postUrl(uri); + request.headers.contentType = ContentType.json; + request.write(jsonEncode({ + 'device_id': deviceId, + 'apns_token': pushToken, + 'bundle_id': bundleId, + 'settings': { + 'live_activity_color': liveActivityColor, + }, + })); + final response = + await request.close().timeout(const Duration(seconds: 10)); + if (response.statusCode != 200 && response.statusCode != 201) { + debugPrint('ServerSync register hiba: ${response.statusCode}'); + } else { + debugPrint('ServerSync: device regisztrálva'); + } + client.close(); + } catch (e) { + debugPrint('ServerSync register kivétel: $e'); + } + } + + Future _uploadSchedule({ + required String deviceId, + required List lessons, + }) async { + try { + final now = DateTime.now(); + final dateStr = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + + final lessonsJson = lessons + .map((l) => { + 'index': l.lessonIndex.toString(), + 'subject': l.subject.renamedTo ?? + ShortSubject.resolve(subject: l.subject).capital(), + 'icon': SubjectIcon.resolveName(subject: l.subject), + 'room': l.room.replaceAll('_', ' '), + 'description': l.description, + 'start': l.start.millisecondsSinceEpoch, + 'end': l.end.millisecondsSinceEpoch, + }) + .toList(); + + final client = HttpClient(); + final uri = Uri.parse('$_baseUrl/schedule'); + final request = await client.postUrl(uri); + request.headers.contentType = ContentType.json; + request.write(jsonEncode({ + 'device_id': deviceId, + 'date': dateStr, + 'lessons': lessonsJson, + })); + final response = + await request.close().timeout(const Duration(seconds: 10)); + if (response.statusCode != 200 && response.statusCode != 201) { + debugPrint('ServerSync schedule hiba: ${response.statusCode}'); + } else { + debugPrint('ServerSync: ${lessons.length} óra feltöltve ($dateStr)'); + } + client.close(); + } catch (e) { + debugPrint('ServerSync schedule kivétel: $e'); + } + } + + List _filterLessons(List lessons) { + return lessons + .where((l) => + l.status?.name != 'Elmaradt' && l.subject.id != '' && !l.isEmpty) + .toList() + ..sort((a, b) => a.start.compareTo(b.start)); + } +}