228 lines
8.5 KiB
Swift
228 lines
8.5 KiB
Swift
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)
|
|
}
|
|
}
|