import AppIntents import Foundation import Security private enum FEDEOSiriError: LocalizedError { case notSignedIn case noTenant case invalidResponse case server(String) var errorDescription: String? { switch self { case .notSignedIn: return "Bitte öffne FEDEO und melde dich zuerst an." case .noTenant: return "Für dieses Konto ist kein Tenant verfügbar." case .invalidResponse: return "Der FEDEO-Server hat eine ungültige Antwort geliefert." case .server(let message): return message } } } private struct FEDEOMeResponse: Decodable { struct User: Decodable { let id: String } let user: User let tenants: [FEDEOTenant] let activeTenant: Int? enum CodingKeys: String, CodingKey { case user, tenants, activeTenant } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) user = try container.decode(User.self, forKey: .user) tenants = try container.decodeIfPresent([FEDEOTenant].self, forKey: .tenants) ?? [] if let number = try? container.decode(Int.self, forKey: .activeTenant) { activeTenant = number } else if let text = try? container.decode(String.self, forKey: .activeTenant) { activeTenant = Int(text) } else { activeTenant = nil } } } private struct FEDEOTenant: Codable, Sendable { let id: Int let name: String let short: String? } private struct FEDEOTask: Codable, Sendable { let id: Int let name: String let categorie: String? let archived: Bool? let userId: String? let user_id: String? let profile: String? } private struct FEDEOTokenResponse: Decodable { let token: String } private actor FEDEOSiriClient { static let shared = FEDEOSiriClient() private let tokenKey = "fedeo.mobile.auth.token" private let serverKey = "fedeo.mobile.server.base" private let preferredTenantKey = "fedeo.mobile.siri.preferred-tenant" private let defaultServer = "https://app.fedeo.de/backend" func loadContext() async throws -> (me: FEDEOMeResponse, token: String) { guard let token = keychainValue(for: tokenKey), !token.isEmpty else { throw FEDEOSiriError.notSignedIn } let me: FEDEOMeResponse = try await request(path: "/api/me", token: token) guard !me.tenants.isEmpty else { throw FEDEOSiriError.noTenant } return (me, token) } func preferredTenantID() -> Int? { keychainValue(for: preferredTenantKey).flatMap(Int.init) } func setPreferredTenantID(_ tenantID: Int) { setKeychainValue(String(tenantID), for: preferredTenantKey) } func accessToken(for tenantID: Int, context: (me: FEDEOMeResponse, token: String)) async throws -> String { if context.me.activeTenant == tenantID { return context.token } let response: FEDEOTokenResponse = try await request( path: "/api/tenant/switch", method: "POST", token: context.token, body: ["tenant_id": String(tenantID)] ) return response.token } func openTasks(tenantID: Int, context: (me: FEDEOMeResponse, token: String)? = nil) async throws -> [FEDEOTask] { let loadedContext: (me: FEDEOMeResponse, token: String) if let context { loadedContext = context } else { loadedContext = try await loadContext() } let token = try await accessToken(for: tenantID, context: loadedContext) let tasks: [FEDEOTask] = try await request(path: "/api/resource/tasks", token: token) return tasks.filter { task in guard task.archived != true, task.categorie != "Abgeschlossen" else { return false } let assignedUser = task.userId ?? task.user_id ?? task.profile return assignedUser == nil || assignedUser == loadedContext.me.user.id } } func createTask(name: String, tenantID: Int, context: (me: FEDEOMeResponse, token: String)) async throws -> FEDEOTask { let token = try await accessToken(for: tenantID, context: context) return try await request( path: "/api/resource/tasks", method: "POST", token: token, body: ["name": name, "categorie": "Offen", "userId": context.me.user.id] ) } @available(iOS 16.0, *) func completeTask(_ task: FEDEOTodoEntity, context: (me: FEDEOMeResponse, token: String)) async throws { let token = try await accessToken(for: task.tenantID, context: context) let _: FEDEOTask = try await request( path: "/api/resource/tasks/\(task.taskID)", method: "PUT", token: token, body: ["categorie": "Abgeschlossen"] ) } private func request( path: String, method: String = "GET", token: String, body: [String: String]? = nil ) async throws -> T { let base = (keychainValue(for: serverKey) ?? defaultServer).trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard let url = URL(string: base + path) else { throw FEDEOSiriError.invalidResponse } var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = 20 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") if let body { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) } let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FEDEOSiriError.invalidResponse } guard (200..<300).contains(httpResponse.statusCode) else { let payload = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] let message = payload?["message"] as? String ?? payload?["error"] as? String ?? "FEDEO-Anfrage fehlgeschlagen." throw FEDEOSiriError.server(message) } return try JSONDecoder().decode(T.self, from: data) } private func keychainValue(for key: String) -> String? { let keyData = Data(key.utf8) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "app:no-auth", kSecAttrAccount as String: keyData, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] var result: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil } return String(data: data, encoding: .utf8) } private func setKeychainValue(_ value: String, for key: String) { let keyData = Data(key.utf8) let baseQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "app:no-auth", kSecAttrAccount as String: keyData, ] let valueData = Data(value.utf8) if SecItemUpdate(baseQuery as CFDictionary, [kSecValueData as String: valueData] as CFDictionary) == errSecItemNotFound { var insert = baseQuery insert[kSecAttrGeneric as String] = keyData insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock insert[kSecValueData as String] = valueData SecItemAdd(insert as CFDictionary, nil) } } } @available(iOS 16.0, *) struct FEDEOTenantEntity: AppEntity { static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "FEDEO-Tenant") static var defaultQuery = FEDEOTenantQuery() let id: Int let name: String var displayRepresentation: DisplayRepresentation { DisplayRepresentation(title: "\(name)") } } @available(iOS 16.0, *) struct FEDEOTenantQuery: EntityStringQuery { func entities(for identifiers: [Int]) async throws -> [FEDEOTenantEntity] { let context = try await FEDEOSiriClient.shared.loadContext() return context.me.tenants.filter { identifiers.contains($0.id) }.map { .init(id: $0.id, name: $0.name) } } func suggestedEntities() async throws -> [FEDEOTenantEntity] { let context = try await FEDEOSiriClient.shared.loadContext() let preferred = await FEDEOSiriClient.shared.preferredTenantID() ?? context.me.activeTenant return context.me.tenants .sorted { ($0.id == preferred ? 0 : 1, $0.name) < ($1.id == preferred ? 0 : 1, $1.name) } .map { .init(id: $0.id, name: $0.name) } } func entities(matching string: String) async throws -> [FEDEOTenantEntity] { let terms = string.lowercased().split(separator: " ") return try await suggestedEntities().filter { tenant in let name = tenant.name.lowercased() return terms.allSatisfy(name.contains) } } } @available(iOS 16.0, *) struct FEDEOTodoEntity: AppEntity { static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "FEDEO-Todo") static var defaultQuery = FEDEOTodoQuery() let id: String let taskID: Int let tenantID: Int let name: String let tenantName: String var displayRepresentation: DisplayRepresentation { DisplayRepresentation(title: "\(name)", subtitle: "\(tenantName)") } } @available(iOS 16.0, *) struct FEDEOTodoQuery: EntityStringQuery { func entities(for identifiers: [String]) async throws -> [FEDEOTodoEntity] { let context = try await FEDEOSiriClient.shared.loadContext() var result: [FEDEOTodoEntity] = [] for tenant in context.me.tenants { let requested = identifiers.filter { $0.hasPrefix("\(tenant.id):") } guard !requested.isEmpty else { continue } let tasks = try await FEDEOSiriClient.shared.openTasks(tenantID: tenant.id, context: context) result += tasks.filter { requested.contains("\(tenant.id):\($0.id)") }.map { .init(id: "\(tenant.id):\($0.id)", taskID: $0.id, tenantID: tenant.id, name: $0.name, tenantName: tenant.name) } } return result } func suggestedEntities() async throws -> [FEDEOTodoEntity] { let context = try await FEDEOSiriClient.shared.loadContext() let preferredID = await FEDEOSiriClient.shared.preferredTenantID() ?? context.me.activeTenant guard let tenant = context.me.tenants.first(where: { $0.id == preferredID }) ?? context.me.tenants.first else { return [] } let tasks = try await FEDEOSiriClient.shared.openTasks(tenantID: tenant.id, context: context) return tasks.map { .init(id: "\(tenant.id):\($0.id)", taskID: $0.id, tenantID: tenant.id, name: $0.name, tenantName: tenant.name) } } func entities(matching string: String) async throws -> [FEDEOTodoEntity] { let terms = string.lowercased().split(separator: " ") return try await suggestedEntities().filter { todo in let name = todo.name.lowercased() return terms.allSatisfy(name.contains) } } } @available(iOS 16.0, *) private protocol FEDEOTenantResolving {} @available(iOS 16.0, *) extension FEDEOTenantResolving { func availableTenants(context: (me: FEDEOMeResponse, token: String)) -> [FEDEOTenantEntity] { context.me.tenants.map { .init(id: $0.id, name: $0.name) } } } @available(iOS 16.0, *) struct FEDEOCreateTodoIntent: AppIntent, FEDEOTenantResolving { static var title: LocalizedStringResource = "Todo in FEDEO erstellen" static var description = IntentDescription("Erstellt ein neues Todo im ausgewählten FEDEO-Tenant.") @Parameter(title: "Todo") var name: String @Parameter(title: "Tenant") var tenant: FEDEOTenantEntity? static var parameterSummary: some ParameterSummary { Summary("Todo \(\.$name) in \(\.$tenant) erstellen") } func perform() async throws -> some IntentResult & ProvidesDialog { let context = try await FEDEOSiriClient.shared.loadContext() let selected = try await resolveTenant(context: context) let task = try await FEDEOSiriClient.shared.createTask(name: name, tenantID: selected.id, context: context) await FEDEOSiriClient.shared.setPreferredTenantID(selected.id) return .result(dialog: "Todo „\(task.name)“ wurde bei \(selected.name) erstellt.") } private func resolveTenant(context: (me: FEDEOMeResponse, token: String)) async throws -> FEDEOTenantEntity { if let tenant { return tenant } if let preferredID = await FEDEOSiriClient.shared.preferredTenantID(), let preferred = context.me.tenants.first(where: { $0.id == preferredID }) { return .init(id: preferred.id, name: preferred.name) } let currentName = context.me.tenants.first { $0.id == context.me.activeTenant }?.name ?? context.me.tenants[0].name return try await $tenant.requestDisambiguation( among: availableTenants(context: context), dialog: "Soll das Todo bei \(currentName) erstellt werden oder möchtest du den Tenant wechseln?" ) } } @available(iOS 16.0, *) struct FEDEOListOpenTodosIntent: AppIntent, FEDEOTenantResolving { static var title: LocalizedStringResource = "Offene FEDEO-Todos anzeigen" static var description = IntentDescription("Zeigt deine offenen Todos in einem FEDEO-Tenant.") @Parameter(title: "Tenant") var tenant: FEDEOTenantEntity? func perform() async throws -> some IntentResult & ReturnsValue<[FEDEOTodoEntity]> & ProvidesDialog { let context = try await FEDEOSiriClient.shared.loadContext() let selected = try await resolveTenant(context: context) let tasks = try await FEDEOSiriClient.shared.openTasks(tenantID: selected.id, context: context) await FEDEOSiriClient.shared.setPreferredTenantID(selected.id) let todos = tasks.map { FEDEOTodoEntity(id: "\(selected.id):\($0.id)", taskID: $0.id, tenantID: selected.id, name: $0.name, tenantName: selected.name) } let names = todos.prefix(5).map(\.name).joined(separator: ", ") let dialog = todos.isEmpty ? "Du hast bei \(selected.name) keine offenen Todos." : "Bei \(selected.name) sind \(todos.count) Todos offen: \(names)." return .result(value: todos, dialog: IntentDialog(stringLiteral: dialog)) } private func resolveTenant(context: (me: FEDEOMeResponse, token: String)) async throws -> FEDEOTenantEntity { if let tenant { return tenant } if let preferredID = await FEDEOSiriClient.shared.preferredTenantID(), let preferred = context.me.tenants.first(where: { $0.id == preferredID }) { return .init(id: preferred.id, name: preferred.name) } return try await $tenant.requestDisambiguation(among: availableTenants(context: context), dialog: "Für welchen Tenant soll ich die offenen Todos anzeigen?") } } @available(iOS 16.0, *) struct FEDEOCompleteTodoIntent: AppIntent { static var title: LocalizedStringResource = "FEDEO-Todo erledigen" static var description = IntentDescription("Markiert ein offenes FEDEO-Todo als abgeschlossen.") @Parameter(title: "Todo") var todo: FEDEOTodoEntity static var parameterSummary: some ParameterSummary { Summary("\(\.$todo) erledigen") } func perform() async throws -> some IntentResult & ProvidesDialog { let context = try await FEDEOSiriClient.shared.loadContext() try await FEDEOSiriClient.shared.completeTask(todo, context: context) await FEDEOSiriClient.shared.setPreferredTenantID(todo.tenantID) return .result(dialog: "Todo „\(todo.name)“ wurde bei \(todo.tenantName) erledigt.") } } @available(iOS 16.0, *) struct FEDEOAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut( intent: FEDEOCreateTodoIntent(), phrases: [ "Erstelle ein Todo in \(.applicationName)", "Erstelle ein Todo mit \(.applicationName)", "Neues Todo in \(.applicationName)", "Neues Todo mit \(.applicationName)", ], shortTitle: "Todo erstellen", systemImageName: "checklist" ) AppShortcut( intent: FEDEOListOpenTodosIntent(), phrases: [ "Zeige meine offenen Todos in \(.applicationName)", "Zeige meine offenen Todos mit \(.applicationName)", "Meine Todos in \(.applicationName)", "Meine Todos bei \(.applicationName)", "Was sind meine offenen Todos in \(.applicationName)", ], shortTitle: "Offene Todos", systemImageName: "list.bullet" ) AppShortcut( intent: FEDEOCompleteTodoIntent(), phrases: [ "Erledige ein Todo in \(.applicationName)", "Erledige ein Todo mit \(.applicationName)", "Todo abschließen in \(.applicationName)", "Todo abschließen mit \(.applicationName)", "Erledige \(\.$todo) in \(.applicationName)", "Schließe \(\.$todo) in \(.applicationName) ab", ], shortTitle: "Todo erledigen", systemImageName: "checkmark.circle" ) } }