KI-AGENT: Siri-Kurzbefehle für FEDEO-Todos ergänzen
This commit is contained in:
@@ -5,6 +5,7 @@ import { router } from 'expo-router';
|
||||
import { DEFAULT_API_BASE_URL } from '@/src/config/env';
|
||||
import { sendMobileTestPush } from '@/src/lib/api';
|
||||
import { registerDeviceForPush } from '@/src/lib/push-registration';
|
||||
import { getSiriPreferredTenantId, setSiriPreferredTenantId } from '@/src/lib/siri-settings';
|
||||
import {
|
||||
getApiBaseUrlSync,
|
||||
hydrateApiBaseUrl,
|
||||
@@ -21,13 +22,15 @@ function isValidServerUrl(value: string): boolean {
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { logout, token } = useAuth();
|
||||
const { activeTenantId, logout, tenants, token } = useAuth();
|
||||
const [serverUrl, setServerUrlInput] = useState(getApiBaseUrlSync());
|
||||
const [savedUrl, setSavedUrl] = useState(getApiBaseUrlSync());
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pushSubmitting, setPushSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [siriTenantId, setSiriTenantId] = useState<number | null>(null);
|
||||
const [siriSubmitting, setSiriSubmitting] = useState(false);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
const current = await hydrateApiBaseUrl();
|
||||
@@ -37,8 +40,26 @@ export default function SettingsScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfig();
|
||||
void getSiriPreferredTenantId().then(setSiriTenantId);
|
||||
}, [loadConfig]);
|
||||
|
||||
async function onSelectSiriTenant(tenantId: number | null) {
|
||||
setSiriSubmitting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
await setSiriPreferredTenantId(tenantId);
|
||||
setSiriTenantId(tenantId);
|
||||
setSuccess(tenantId === null
|
||||
? 'Siri fragt beim nächsten Befehl wieder nach dem Tenant.'
|
||||
: 'Siri-Standardtenant gespeichert.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Siri-Standardtenant konnte nicht gespeichert werden.');
|
||||
} finally {
|
||||
setSiriSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
@@ -175,6 +196,43 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Siri & Kurzbefehle</Text>
|
||||
<Text style={styles.hint}>
|
||||
Der Siri-Standardtenant gilt nur für Sprachbefehle. Der aktuell in FEDEO geöffnete Tenant wird dadurch
|
||||
nicht gewechselt. Ohne Auswahl fragt Siri beim nächsten Befehl nach.
|
||||
</Text>
|
||||
|
||||
{tenants.map((tenant) => {
|
||||
const tenantId = Number(tenant.id);
|
||||
const selected = tenantId === siriTenantId;
|
||||
return (
|
||||
<Pressable
|
||||
key={String(tenant.id)}
|
||||
style={[styles.tenantButton, selected ? styles.tenantButtonSelected : null]}
|
||||
onPress={() => onSelectSiriTenant(tenantId)}
|
||||
disabled={siriSubmitting}>
|
||||
<View style={styles.tenantTextWrap}>
|
||||
<Text style={[styles.tenantName, selected ? styles.tenantNameSelected : null]}>{tenant.name}</Text>
|
||||
{tenantId === activeTenantId ? <Text style={styles.meta}>Aktuell in FEDEO geöffnet</Text> : null}
|
||||
</View>
|
||||
<Text style={[styles.tenantAction, selected ? styles.tenantActionSelected : null]}>
|
||||
{selected ? 'Siri-Standard' : 'Auswählen'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
<Pressable
|
||||
style={[styles.resetButton, siriSubmitting ? styles.buttonDisabled : null]}
|
||||
onPress={() => onSelectSiriTenant(null)}
|
||||
disabled={siriSubmitting || siriTenantId === null}>
|
||||
<Text style={styles.resetButtonText}>Bei nächstem Befehl nachfragen</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.meta}>Verfügbar: Todo erstellen · Offene Todos anzeigen · Todo erledigen</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Mobile Push</Text>
|
||||
<Text style={styles.hint}>
|
||||
@@ -247,6 +305,25 @@ const styles = StyleSheet.create({
|
||||
color: '#6b7280',
|
||||
fontSize: 12,
|
||||
},
|
||||
tenantButton: {
|
||||
minHeight: 52,
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 9,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
tenantButtonSelected: { borderColor: PRIMARY, backgroundColor: '#eff9ea' },
|
||||
tenantTextWrap: { flex: 1 },
|
||||
tenantName: { color: '#111827', fontSize: 14, fontWeight: '600' },
|
||||
tenantNameSelected: { color: '#2f5f24' },
|
||||
tenantAction: { color: '#6b7280', fontSize: 12, fontWeight: '600' },
|
||||
tenantActionSelected: { color: '#3d7a30' },
|
||||
actions: {
|
||||
gap: 8,
|
||||
marginTop: 6,
|
||||
|
||||
403
mobile/plugins/ios/FEDEOSiriIntents.swift
Normal file
403
mobile/plugins/ios/FEDEOSiriIntents.swift
Normal file
@@ -0,0 +1,403 @@
|
||||
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<T: Decodable>(
|
||||
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)", "Neues Todo in \(.applicationName)"],
|
||||
shortTitle: "Todo erstellen",
|
||||
systemImageName: "checklist"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: FEDEOListOpenTodosIntent(),
|
||||
phrases: ["Zeige meine offenen Todos in \(.applicationName)", "Meine Todos in \(.applicationName)"],
|
||||
shortTitle: "Offene Todos",
|
||||
systemImageName: "list.bullet"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: FEDEOCompleteTodoIntent(),
|
||||
phrases: ["Erledige ein Todo in \(.applicationName)", "Todo abschließen in \(.applicationName)"],
|
||||
shortTitle: "Todo erledigen",
|
||||
systemImageName: "checkmark.circle"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ const path = require('node:path');
|
||||
const { withXcodeProject } = require('@expo/config-plugins');
|
||||
|
||||
const SHARE_EXTENSION_DIRECTORY = 'InFEDEOhochladen';
|
||||
const SIRI_INTENTS_FILE = 'FEDEOSiriIntents.swift';
|
||||
|
||||
function replaceOnce(source, search, replacement, label) {
|
||||
if (!source.includes(search)) {
|
||||
@@ -13,6 +14,34 @@ function replaceOnce(source, search, replacement, label) {
|
||||
|
||||
module.exports = function withShareIntentMultifile(config) {
|
||||
return withXcodeProject(config, async (modConfig) => {
|
||||
const siriTemplatePath = path.join(__dirname, 'ios', SIRI_INTENTS_FILE);
|
||||
const siriDestinationPath = path.join(modConfig.modRequest.platformProjectRoot, 'FEDEO', SIRI_INTENTS_FILE);
|
||||
fs.copyFileSync(siriTemplatePath, siriDestinationPath);
|
||||
|
||||
const project = modConfig.modResults;
|
||||
const appTarget = project.pbxTargetByName('FEDEO');
|
||||
const appGroup = project.pbxGroupByName('FEDEO');
|
||||
const appTargetUUID = Object.entries(project.pbxNativeTargetSection()).find(
|
||||
([key, entry]) => !key.endsWith('_comment') && entry === appTarget
|
||||
)?.[0];
|
||||
const appGroupUUID = Object.entries(project.hash.project.objects.PBXGroup).find(
|
||||
([key, entry]) => !key.endsWith('_comment') && entry === appGroup
|
||||
)?.[0];
|
||||
const fileReferences = project.pbxFileReferenceSection();
|
||||
const siriFileExists = Object.values(fileReferences).some(
|
||||
(entry) => entry && typeof entry === 'object' && String(entry.path || entry.name || '').includes(SIRI_INTENTS_FILE)
|
||||
);
|
||||
if (!siriFileExists) {
|
||||
if (!appTargetUUID || !appGroupUUID) {
|
||||
throw new Error('FEDEO-App-Target für Siri App Intents wurde nicht gefunden.');
|
||||
}
|
||||
project.addSourceFile(
|
||||
`FEDEO/${SIRI_INTENTS_FILE}`,
|
||||
{ target: appTargetUUID },
|
||||
appGroupUUID
|
||||
);
|
||||
}
|
||||
|
||||
const controllerPath = path.join(
|
||||
modConfig.modRequest.platformProjectRoot,
|
||||
SHARE_EXTENSION_DIRECTORY,
|
||||
|
||||
18
mobile/src/lib/siri-settings.ts
Normal file
18
mobile/src/lib/siri-settings.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const SIRI_PREFERRED_TENANT_KEY = 'fedeo.mobile.siri.preferred-tenant';
|
||||
|
||||
export async function getSiriPreferredTenantId(): Promise<number | null> {
|
||||
const stored = await SecureStore.getItemAsync(SIRI_PREFERRED_TENANT_KEY);
|
||||
if (!stored) return null;
|
||||
const tenantId = Number(stored);
|
||||
return Number.isFinite(tenantId) ? tenantId : null;
|
||||
}
|
||||
|
||||
export async function setSiriPreferredTenantId(tenantId: number | null): Promise<void> {
|
||||
if (tenantId === null) {
|
||||
await SecureStore.deleteItemAsync(SIRI_PREFERRED_TENANT_KEY);
|
||||
return;
|
||||
}
|
||||
await SecureStore.setItemAsync(SIRI_PREFERRED_TENANT_KEY, String(tenantId));
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const TOKEN_KEY = 'fedeo.mobile.auth.token';
|
||||
const REFRESH_TOKEN_KEY = 'fedeo.mobile.auth.refresh-token';
|
||||
const SIRI_PREFERRED_TENANT_KEY = 'fedeo.mobile.siri.preferred-tenant';
|
||||
|
||||
let memoryToken: string | null = null;
|
||||
let memoryRefreshToken: string | null = null;
|
||||
@@ -71,6 +72,7 @@ export async function clearStoredToken(): Promise<void> {
|
||||
await Promise.all([
|
||||
SecureStore.deleteItemAsync(TOKEN_KEY),
|
||||
SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY),
|
||||
SecureStore.deleteItemAsync(SIRI_PREFERRED_TENANT_KEY),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user