KasztelanKarta/Kasztelan/KasztelanApp.swift

244 lines
9.1 KiB
Swift

import SwiftUI
import UIKit
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
init() {
let navigationAppearance = UINavigationBarAppearance()
navigationAppearance.configureWithTransparentBackground()
navigationAppearance.backgroundColor = .clear
navigationAppearance.backgroundEffect = nil
navigationAppearance.shadowColor = .clear
let navigationBar = UINavigationBar.appearance()
navigationBar.standardAppearance = navigationAppearance
navigationBar.scrollEdgeAppearance = navigationAppearance
navigationBar.compactAppearance = navigationAppearance
navigationBar.isTranslucent = true
navigationBar.backgroundColor = .clear
}
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 {
AmbientBackground()
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)
}
}