KI-AGENT: Siri-Kurzbefehle für FEDEO-Todos ergänzen

This commit is contained in:
2026-08-08 22:59:15 +02:00
parent 5dae5b6aa5
commit ecbfdaa0cb
5 changed files with 530 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ import { router } from 'expo-router';
import { DEFAULT_API_BASE_URL } from '@/src/config/env'; import { DEFAULT_API_BASE_URL } from '@/src/config/env';
import { sendMobileTestPush } from '@/src/lib/api'; import { sendMobileTestPush } from '@/src/lib/api';
import { registerDeviceForPush } from '@/src/lib/push-registration'; import { registerDeviceForPush } from '@/src/lib/push-registration';
import { getSiriPreferredTenantId, setSiriPreferredTenantId } from '@/src/lib/siri-settings';
import { import {
getApiBaseUrlSync, getApiBaseUrlSync,
hydrateApiBaseUrl, hydrateApiBaseUrl,
@@ -21,13 +22,15 @@ function isValidServerUrl(value: string): boolean {
} }
export default function SettingsScreen() { export default function SettingsScreen() {
const { logout, token } = useAuth(); const { activeTenantId, logout, tenants, token } = useAuth();
const [serverUrl, setServerUrlInput] = useState(getApiBaseUrlSync()); const [serverUrl, setServerUrlInput] = useState(getApiBaseUrlSync());
const [savedUrl, setSavedUrl] = useState(getApiBaseUrlSync()); const [savedUrl, setSavedUrl] = useState(getApiBaseUrlSync());
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [pushSubmitting, setPushSubmitting] = useState(false); const [pushSubmitting, setPushSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = 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 loadConfig = useCallback(async () => {
const current = await hydrateApiBaseUrl(); const current = await hydrateApiBaseUrl();
@@ -37,8 +40,26 @@ export default function SettingsScreen() {
useEffect(() => { useEffect(() => {
void loadConfig(); void loadConfig();
void getSiriPreferredTenantId().then(setSiriTenantId);
}, [loadConfig]); }, [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() { async function onSave() {
setError(null); setError(null);
setSuccess(null); setSuccess(null);
@@ -175,6 +196,43 @@ export default function SettingsScreen() {
</View> </View>
</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}> <View style={styles.card}>
<Text style={styles.title}>Mobile Push</Text> <Text style={styles.title}>Mobile Push</Text>
<Text style={styles.hint}> <Text style={styles.hint}>
@@ -247,6 +305,25 @@ const styles = StyleSheet.create({
color: '#6b7280', color: '#6b7280',
fontSize: 12, 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: { actions: {
gap: 8, gap: 8,
marginTop: 6, marginTop: 6,

View 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"
)
}
}

View File

@@ -3,6 +3,7 @@ const path = require('node:path');
const { withXcodeProject } = require('@expo/config-plugins'); const { withXcodeProject } = require('@expo/config-plugins');
const SHARE_EXTENSION_DIRECTORY = 'InFEDEOhochladen'; const SHARE_EXTENSION_DIRECTORY = 'InFEDEOhochladen';
const SIRI_INTENTS_FILE = 'FEDEOSiriIntents.swift';
function replaceOnce(source, search, replacement, label) { function replaceOnce(source, search, replacement, label) {
if (!source.includes(search)) { if (!source.includes(search)) {
@@ -13,6 +14,34 @@ function replaceOnce(source, search, replacement, label) {
module.exports = function withShareIntentMultifile(config) { module.exports = function withShareIntentMultifile(config) {
return withXcodeProject(config, async (modConfig) => { 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( const controllerPath = path.join(
modConfig.modRequest.platformProjectRoot, modConfig.modRequest.platformProjectRoot,
SHARE_EXTENSION_DIRECTORY, SHARE_EXTENSION_DIRECTORY,

View 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));
}

View File

@@ -2,6 +2,7 @@ import * as SecureStore from 'expo-secure-store';
const TOKEN_KEY = 'fedeo.mobile.auth.token'; const TOKEN_KEY = 'fedeo.mobile.auth.token';
const REFRESH_TOKEN_KEY = 'fedeo.mobile.auth.refresh-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 memoryToken: string | null = null;
let memoryRefreshToken: string | null = null; let memoryRefreshToken: string | null = null;
@@ -71,6 +72,7 @@ export async function clearStoredToken(): Promise<void> {
await Promise.all([ await Promise.all([
SecureStore.deleteItemAsync(TOKEN_KEY), SecureStore.deleteItemAsync(TOKEN_KEY),
SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY), SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY),
SecureStore.deleteItemAsync(SIRI_PREFERRED_TENANT_KEY),
]); ]);
} }
} }