import SwiftUI import UserNotifications import BackgroundTasks import CoreSpotlight import UniformTypeIdentifiers final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { private static let backgroundRefreshID = "uk.co.kasztelan.app.ios.refresh" func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { UNUserNotificationCenter.current().delegate = self let openAction = UNNotificationAction( identifier: "KASZTELAN_OPEN", title: "Otwórz wiadomość", options: [.foreground] ) let messageCategory = UNNotificationCategory( identifier: "KASZTELAN_MESSAGE", actions: [openAction], intentIdentifiers: [], options: [] ) UNUserNotificationCenter.current().setNotificationCategories([messageCategory]) BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.backgroundRefreshID, using: nil) { [weak self] task in guard let refreshTask = task as? BGAppRefreshTask else { task.setTaskCompleted(success: false) return } self?.handle(refreshTask) } indexInSpotlight() scheduleBackgroundRefresh() return true } private func indexInSpotlight() { let attributes = CSSearchableItemAttributeSet(contentType: .application) attributes.title = "Kasztelan" attributes.displayName = "Kasztelan" attributes.contentDescription = "Karta klienta Kasztelan, punkty i wiadomości" attributes.keywords = ["Kasztelan", "KASZTELAN", "karta Kasztelan", "punkty", "loyalty card"] let item = CSSearchableItem( uniqueIdentifier: "uk.co.kasztelan.app.ios.open", domainIdentifier: "uk.co.kasztelan.app.ios", attributeSet: attributes ) item.expirationDate = .distantFuture CSSearchableIndex.default().indexSearchableItems([item]) } func applicationDidEnterBackground(_ application: UIApplication) { scheduleBackgroundRefresh() } private func scheduleBackgroundRefresh() { BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.backgroundRefreshID) let request = BGAppRefreshTaskRequest(identifier: Self.backgroundRefreshID) request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) try? BGTaskScheduler.shared.submit(request) } private func handle(_ backgroundTask: BGAppRefreshTask) { scheduleBackgroundRefresh() let operation = Task { let token = KeychainStore.load() guard !token.isEmpty else { backgroundTask.setTaskCompleted(success: true) return } do { let store = MessageStore() let lastServerID = store.lastServerID let response = try await APIClient().notifications(after: store.lastServerID, token: token) var incoming = response.notifications ?? [] if incoming.isEmpty, let latestResponse = try? await APIClient().latestNotification(token: token), let latest = latestResponse.notification, latest.id > lastServerID { incoming = [latest] } let added = store.merge(incoming) for message in added { await NotificationService.show(message) } backgroundTask.setTaskCompleted(success: true) } catch { backgroundTask.setTaskCompleted(success: false) } } backgroundTask.expirationHandler = { operation.cancel() } } func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { completionHandler([.banner, .list, .sound]) } func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { NotificationCenter.default.post(name: .kasztelanOpenMessages, object: nil) completionHandler() } } extension Notification.Name { static let kasztelanOpenMessages = Notification.Name("kasztelan.openMessages") } @main struct KasztelanApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var model = AppModel() @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { RootView() .environmentObject(model) .preferredColorScheme(.dark) .task { await model.launch() } .onOpenURL { url in if url.host == "message" { model.selectedTab = 1 } } .onReceive(NotificationCenter.default.publisher(for: .kasztelanOpenMessages)) { _ in model.selectedTab = 1 } } .onChange(of: scenePhase) { phase in if phase == .active { model.beginLiveRefresh() } else { model.stopLiveRefresh() } } } } struct RootView: View { @EnvironmentObject private var model: AppModel var body: some View { ZStack { KTheme.background.ignoresSafeArea() if model.isLoading { ProgressView(model.text("Sprawdzam konto…", "Checking your account…")) .tint(KTheme.orange) .foregroundStyle(.white) } else if model.isAuthenticated { MainTabView() } else { AuthView() } } .alert(model.text("Kasztelan", "Kasztelan"), isPresented: Binding( get: { model.errorMessage != nil }, set: { if !$0 { model.errorMessage = nil } } )) { Button("OK", role: .cancel) { model.errorMessage = nil } } message: { Text(model.errorMessage ?? "") } .overlay { if model.isBusy { ZStack { Color.black.opacity(0.3).ignoresSafeArea() ProgressView().controlSize(.large).tint(KTheme.orange) } } } .overlay(alignment: .top) { if let message = model.presentedMessage { InAppMessageBanner(message: message) .environmentObject(model) .padding(.horizontal, 14) .padding(.top, 8) .transition(.move(edge: .top).combined(with: .opacity)) .zIndex(10) } } .animation(.spring(response: 0.35, dampingFraction: 0.86), value: model.presentedMessage?.id) } } private struct InAppMessageBanner: View { @EnvironmentObject private var model: AppModel let message: KasztelanMessage var body: some View { HStack(spacing: 12) { Image("Logo") .resizable() .scaledToFit() .frame(width: 48, height: 36) VStack(alignment: .leading, spacing: 2) { Text("KASZTELAN") .font(.caption.bold()) .foregroundStyle(KTheme.orange) Text(message.title.isEmpty ? "Kasztelan" : message.title) .font(.headline) .foregroundStyle(.white) .lineLimit(2) } Spacer(minLength: 4) Button { model.dismissPresentedMessage() } label: { Image(systemName: "xmark.circle.fill") .font(.title3) .foregroundStyle(.secondary) } .buttonStyle(.plain) .accessibilityLabel(model.text("Zamknij", "Close")) } .padding(14) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 18, style: .continuous) .stroke(KTheme.orange.opacity(0.45), lineWidth: 1) } .shadow(color: .black.opacity(0.3), radius: 14, y: 8) .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .onTapGesture { model.openPresentedMessage() } .accessibilityLabel(message.title) } }