Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Push Docker Images / build-central-services-admin (push) Successful in 35s
Build and Push Docker Images / build-docs (push) Successful in 34s
Build and Push Docker Images / build-backend (push) Successful in 1m0s
Build and Push Docker Images / build-frontend (push) Successful in 2m0s
Build and Push Docker Images / build-website (push) Successful in 37s
Build and Push Docker Images / build-central-services-api (push) Successful in 35s
All checks were successful
Build and Push Docker Images / build-central-services-admin (push) Successful in 35s
Build and Push Docker Images / build-docs (push) Successful in 34s
Build and Push Docker Images / build-backend (push) Successful in 1m0s
Build and Push Docker Images / build-frontend (push) Successful in 2m0s
Build and Push Docker Images / build-website (push) Successful in 37s
Build and Push Docker Images / build-central-services-api (push) Successful in 35s
This commit is contained in:
@@ -177,11 +177,49 @@ export default async function exportRoutes(server: FastifyInstance) {
|
||||
.from(generatedexports)
|
||||
.where(eq(generatedexports.tenantId, req.user.tenant_id))
|
||||
|
||||
console.log(data)
|
||||
reply.send(data)
|
||||
reply.send(data.map(item => ({
|
||||
...item,
|
||||
url: `/api/exports/${item.id}/download`,
|
||||
})))
|
||||
|
||||
})
|
||||
|
||||
server.get("/exports/:id/download", async (req, reply) => {
|
||||
const { id } = req.params as { id: string }
|
||||
const exportId = Number(id)
|
||||
if (!Number.isSafeInteger(exportId)) {
|
||||
return reply.code(404).send({ error: "Export not found" })
|
||||
}
|
||||
|
||||
const [item] = await server.db
|
||||
.select()
|
||||
.from(generatedexports)
|
||||
.where(eq(generatedexports.id, exportId))
|
||||
|
||||
if (
|
||||
!item ||
|
||||
item.tenantId !== req.user.tenant_id ||
|
||||
item.validUntil.getTime() <= Date.now()
|
||||
) {
|
||||
return reply.code(404).send({ error: "Export not found" })
|
||||
}
|
||||
|
||||
try {
|
||||
const { Body, ContentType } = await s3.send(new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: item.filePath,
|
||||
}))
|
||||
const filename = item.filePath.split("/").pop() || `export-${item.id}`
|
||||
|
||||
reply.header("Content-Type", ContentType || "application/octet-stream")
|
||||
reply.header("Content-Disposition", `attachment; filename="${filename}"`)
|
||||
return reply.send(Body as any)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return reply.code(500).send({ error: "Could not download export" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { s3 } from "../utils/s3"
|
||||
import {
|
||||
GetObjectCommand
|
||||
} from "@aws-sdk/client-s3"
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||
import archiver from "archiver"
|
||||
import { secrets } from "../utils/secrets"
|
||||
import { saveFile } from "../utils/files"
|
||||
@@ -241,6 +240,28 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
}
|
||||
})
|
||||
|
||||
// Dateien über das Backend ausliefern, damit interne S3-Endpunkte wie
|
||||
// http://minio:9000 niemals als URL an den Browser gelangen.
|
||||
server.get("/files/content/:id", async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id: string }
|
||||
const file = await loadSingleFileForRequest(req, id)
|
||||
if (!file) return reply.code(404).send({ error: "File not found" })
|
||||
|
||||
const { Body, ContentType } = await s3.send(new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: file.path!
|
||||
}))
|
||||
|
||||
reply.header("Content-Type", ContentType || "application/octet-stream")
|
||||
reply.header("Content-Disposition", "inline")
|
||||
return reply.send(Body as any)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Could not load file" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
@@ -261,13 +282,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
const file = await loadSingleFileForRequest(req, id)
|
||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
const url = await getSignedUrl(
|
||||
s3,
|
||||
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path! }),
|
||||
{ expiresIn: 900 }
|
||||
)
|
||||
|
||||
return { ...file, url }
|
||||
return { ...file, url: `/api/files/content/${file.id}` }
|
||||
} else {
|
||||
// -------------------------------------------------
|
||||
// MULTIPLE PRESIGNED URLs
|
||||
@@ -283,26 +298,10 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
|
||||
const selected = rows.filter(f => ids.includes(f.id) && f.path)
|
||||
|
||||
console.log(selected)
|
||||
|
||||
const url = await getSignedUrl(
|
||||
s3,
|
||||
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: selected[0].path! }),
|
||||
{ expiresIn: 900 }
|
||||
)
|
||||
console.log(url)
|
||||
console.log(selected.filter(f => !f.path))
|
||||
|
||||
const output = await Promise.all(
|
||||
selected.map(async (file) => {
|
||||
const url = await getSignedUrl(
|
||||
s3,
|
||||
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path! }),
|
||||
{ expiresIn: 900 }
|
||||
)
|
||||
return { ...file, url }
|
||||
})
|
||||
)
|
||||
const output = selected.map(file => ({
|
||||
...file,
|
||||
url: `/api/files/content/${file.id}`
|
||||
}))
|
||||
|
||||
return { files: output }
|
||||
}
|
||||
|
||||
@@ -62,13 +62,19 @@ const setupPage = async () => {
|
||||
|
||||
setupPage()
|
||||
|
||||
function downloadFile(row) {
|
||||
async function downloadFile(row) {
|
||||
const response = await useNuxtApp().$api.raw(row.url, {
|
||||
method: "GET",
|
||||
responseType: "blob"
|
||||
})
|
||||
const objectUrl = URL.createObjectURL(response._data as Blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = row.url
|
||||
a.href = objectUrl
|
||||
a.download = row.file_path.split("/").pop()
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
|
||||
const showCreateExportModal = ref(false)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "software.federspiel.fedeo",
|
||||
"buildNumber": "7",
|
||||
"buildNumber": "11",
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.",
|
||||
"NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
|
||||
@@ -56,7 +56,21 @@
|
||||
],
|
||||
"react-native-ble-plx",
|
||||
"expo-notifications",
|
||||
"expo-web-browser"
|
||||
"expo-web-browser",
|
||||
"./plugins/with-share-intent-multifile",
|
||||
[
|
||||
"expo-share-intent",
|
||||
{
|
||||
"iosShareExtensionName": "In FEDEO hochladen",
|
||||
"iosAppGroupIdentifier": "group.software.federspiel.fedeo.share",
|
||||
"iosActivationRules": {
|
||||
"NSExtensionActivationSupportsFileWithMaxCount": 10,
|
||||
"NSExtensionActivationSupportsImageWithMaxCount": 10,
|
||||
"NSExtensionActivationSupportsMovieWithMaxCount": 10
|
||||
},
|
||||
"disableAndroid": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { router } from 'expo-router';
|
||||
|
||||
import { createProject, Customer, fetchCustomers, fetchPlants, fetchProjects, Plant, Project } from '@/src/lib/api';
|
||||
import { filterProjects, getActiveProjectPhase, isProjectCompleted } from '@/src/lib/resource-list-filters';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
const PRIMARY = '#69c350';
|
||||
@@ -24,20 +25,6 @@ function getProjectLine(project: Project): string {
|
||||
return project.name;
|
||||
}
|
||||
|
||||
function getActivePhaseLabel(project: Project): string {
|
||||
const explicit = String(project.active_phase || '').trim();
|
||||
if (explicit) return explicit;
|
||||
|
||||
const phases = Array.isArray(project.phases) ? project.phases : [];
|
||||
const active = phases.find((phase: any) => phase?.active);
|
||||
return String(active?.label || '').trim();
|
||||
}
|
||||
|
||||
function isProjectCompletedByPhase(project: Project): boolean {
|
||||
const phase = getActivePhaseLabel(project).toLowerCase();
|
||||
return phase === 'abgeschlossen';
|
||||
}
|
||||
|
||||
export default function ProjectsScreen() {
|
||||
const { token } = useAuth();
|
||||
|
||||
@@ -64,31 +51,10 @@ export default function ProjectsScreen() {
|
||||
const [customerSearch, setCustomerSearch] = useState('');
|
||||
const [plantSearch, setPlantSearch] = useState('');
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const terms = search
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
return projects.filter((project) => {
|
||||
if (!showArchived && isProjectCompletedByPhase(project)) return false;
|
||||
if (terms.length === 0) return true;
|
||||
|
||||
const haystack = [
|
||||
project.name,
|
||||
project.projectNumber,
|
||||
project.notes,
|
||||
project.customerRef,
|
||||
project.active_phase,
|
||||
getActivePhaseLabel(project),
|
||||
]
|
||||
.map((value) => String(value || '').toLowerCase())
|
||||
.join(' ');
|
||||
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
});
|
||||
}, [projects, search, showArchived]);
|
||||
const filteredProjects = useMemo(
|
||||
() => filterProjects(projects, search, showArchived),
|
||||
[projects, search, showArchived]
|
||||
);
|
||||
|
||||
const selectedCustomerLabel = useMemo(() => {
|
||||
if (!selectedCustomerId) return 'Kunde auswählen (optional)';
|
||||
@@ -241,10 +207,10 @@ export default function ProjectsScreen() {
|
||||
<Pressable key={String(project.id)} style={styles.row} onPress={() => router.push(`/project/${project.id}`)}>
|
||||
<View style={styles.rowHeader}>
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>{getProjectLine(project)}</Text>
|
||||
{isProjectCompletedByPhase(project) ? <Text style={styles.archivedBadge}>Abgeschlossen</Text> : null}
|
||||
{isProjectCompleted(project) ? <Text style={styles.archivedBadge}>Abgeschlossen</Text> : null}
|
||||
</View>
|
||||
{getActivePhaseLabel(project) ? (
|
||||
<Text style={styles.phaseText} numberOfLines={1}>Phase: {getActivePhaseLabel(project)}</Text>
|
||||
{getActiveProjectPhase(project) ? (
|
||||
<Text style={styles.phaseText} numberOfLines={1}>Phase: {getActiveProjectPhase(project)}</Text>
|
||||
) : null}
|
||||
{project.notes ? <Text style={styles.rowSubtitle} numberOfLines={1}>{String(project.notes)}</Text> : null}
|
||||
</Pressable>
|
||||
|
||||
13
mobile/app/+native-intent.ts
Normal file
13
mobile/app/+native-intent.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getShareExtensionKey } from 'expo-share-intent';
|
||||
|
||||
export function redirectSystemPath({ path }: { path: string; initial: boolean }) {
|
||||
try {
|
||||
if (path.includes(`dataUrl=${getShareExtensionKey()}`)) {
|
||||
// Zuerst die normale Session laden; der Provider öffnet danach die Upload-Strecke.
|
||||
return '/';
|
||||
}
|
||||
return path;
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,51 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { Href, Stack, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { ShareIntentProvider, useShareIntentContext } from 'expo-share-intent';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { AuthProvider } from '@/src/providers/auth-provider';
|
||||
import { AuthProvider, useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
function ShareIntentRouter() {
|
||||
const router = useRouter();
|
||||
const { hasShareIntent } = useShareIntentContext();
|
||||
const { isBootstrapping, token } = useAuth();
|
||||
const isOpeningRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasShareIntent) {
|
||||
isOpeningRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isBootstrapping && token && !isOpeningRef.current) {
|
||||
isOpeningRef.current = true;
|
||||
router.push('/share-upload' as Href);
|
||||
}
|
||||
}, [hasShareIntent, isBootstrapping, router, token]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Stack>
|
||||
<ShareIntentProvider>
|
||||
<AuthProvider>
|
||||
<ShareIntentRouter />
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="login" options={{ title: 'Login', headerBackVisible: false }} />
|
||||
<Stack.Screen name="tenant-select" options={{ title: 'Tenant auswählen', headerBackVisible: false }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="share-upload"
|
||||
options={{
|
||||
title: 'In FEDEO hochladen',
|
||||
headerBackVisible: false,
|
||||
gestureEnabled: false,
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="project/[id]"
|
||||
options={{
|
||||
@@ -102,8 +136,9 @@ export default function RootLayout() {
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<StatusBar style="dark" />
|
||||
</AuthProvider>
|
||||
</Stack>
|
||||
<StatusBar style="dark" />
|
||||
</AuthProvider>
|
||||
</ShareIntentProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
import { createCustomer, Customer, fetchCustomers } from '@/src/lib/api';
|
||||
import { filterCustomers } from '@/src/lib/resource-list-filters';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
const PRIMARY = '#69c350';
|
||||
@@ -37,20 +38,10 @@ export default function CustomersScreen() {
|
||||
const [numberInput, setNumberInput] = useState('');
|
||||
const [notesInput, setNotesInput] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
return customers.filter((customer) => {
|
||||
if (!showArchived && customer.archived) return false;
|
||||
if (terms.length === 0) return true;
|
||||
|
||||
const haystack = [customer.name, customer.customerNumber, customer.notes]
|
||||
.map((value) => String(value || '').toLowerCase())
|
||||
.join(' ');
|
||||
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
});
|
||||
}, [customers, search, showArchived]);
|
||||
const filtered = useMemo(
|
||||
() => filterCustomers(customers, search, showArchived),
|
||||
[customers, search, showArchived]
|
||||
);
|
||||
|
||||
const load = useCallback(async (showSpinner = true) => {
|
||||
if (!token) return;
|
||||
|
||||
@@ -15,16 +15,11 @@ import {
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
import { createPlant, Customer, fetchCustomers, fetchPlants, Plant } from '@/src/lib/api';
|
||||
import { filterPlants, getPlantCustomerName } from '@/src/lib/resource-list-filters';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
const PRIMARY = '#69c350';
|
||||
|
||||
function getCustomerName(raw: Plant['customer']): string | null {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === 'object') return raw.name ? String(raw.name) : null;
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
export default function PlantsScreen() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -47,20 +42,10 @@ export default function PlantsScreen() {
|
||||
const [pickerMode, setPickerMode] = useState<'customer' | null>(null);
|
||||
const [customerSearch, setCustomerSearch] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
return plants.filter((plant) => {
|
||||
if (!showArchived && plant.archived) return false;
|
||||
if (terms.length === 0) return true;
|
||||
|
||||
const haystack = [plant.name, plant.description, getCustomerName(plant.customer)]
|
||||
.map((value) => String(value || '').toLowerCase())
|
||||
.join(' ');
|
||||
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
});
|
||||
}, [plants, search, showArchived]);
|
||||
const filtered = useMemo(
|
||||
() => filterPlants(plants, search, showArchived),
|
||||
[plants, search, showArchived]
|
||||
);
|
||||
|
||||
const filteredCustomerOptions = useMemo(() => {
|
||||
const terms = customerSearch.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
@@ -189,8 +174,8 @@ export default function PlantsScreen() {
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>{plant.name}</Text>
|
||||
{plant.archived ? <Text style={styles.badge}>Abgeschlossen</Text> : null}
|
||||
</View>
|
||||
{getCustomerName(plant.customer) ? (
|
||||
<Text style={styles.rowSubtitle} numberOfLines={1}>Kunde: {getCustomerName(plant.customer)}</Text>
|
||||
{getPlantCustomerName(plant.customer) ? (
|
||||
<Text style={styles.rowSubtitle} numberOfLines={1}>Kunde: {getPlantCustomerName(plant.customer)}</Text>
|
||||
) : null}
|
||||
{plant.description ? <Text style={styles.rowSubtitle} numberOfLines={1}>{String(plant.description)}</Text> : null}
|
||||
</Pressable>
|
||||
|
||||
@@ -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,
|
||||
|
||||
306
mobile/app/share-upload.tsx
Normal file
306
mobile/app/share-upload.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useShareIntentContext } from 'expo-share-intent';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import {
|
||||
Customer,
|
||||
fetchCustomers,
|
||||
fetchPlants,
|
||||
fetchProjects,
|
||||
Plant,
|
||||
Project,
|
||||
uploadCustomerFile,
|
||||
uploadPlantFile,
|
||||
uploadProjectFile,
|
||||
} from '@/src/lib/api';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
import {
|
||||
filterCustomers,
|
||||
filterPlants,
|
||||
filterProjects,
|
||||
getActiveProjectPhase,
|
||||
getPlantCustomerName,
|
||||
} from '@/src/lib/resource-list-filters';
|
||||
|
||||
type TargetType = 'project' | 'customer' | 'plant';
|
||||
type UploadTarget = Project | Customer | Plant;
|
||||
|
||||
const TARGET_TYPES: { key: TargetType; label: string; plural: string; icon: keyof typeof Ionicons.glyphMap }[] = [
|
||||
{ key: 'project', label: 'Projekt', plural: 'Projekte', icon: 'folder-outline' },
|
||||
{ key: 'customer', label: 'Kunde', plural: 'Kunden', icon: 'people-outline' },
|
||||
{ key: 'plant', label: 'Objekt', plural: 'Objekte', icon: 'business-outline' },
|
||||
];
|
||||
|
||||
const PRIMARY = '#69c350';
|
||||
|
||||
export default function ShareUploadScreen() {
|
||||
const router = useRouter();
|
||||
const { token } = useAuth();
|
||||
const { shareIntent, resetShareIntent } = useShareIntentContext();
|
||||
const [targetType, setTargetType] = useState<TargetType | null>(null);
|
||||
const [targets, setTargets] = useState<UploadTarget[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadedCount, setUploadedCount] = useState(0);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const files = shareIntent.files || [];
|
||||
const selectedType = TARGET_TYPES.find((type) => type.key === targetType);
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetType || !token) return;
|
||||
|
||||
let isActive = true;
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
setSearch('');
|
||||
|
||||
const request = targetType === 'project'
|
||||
? fetchProjects(token, true)
|
||||
: targetType === 'customer'
|
||||
? fetchCustomers(token, true)
|
||||
: fetchPlants(token, true);
|
||||
|
||||
void request
|
||||
.then((rows) => {
|
||||
if (isActive) setTargets(rows.sort((a, b) => a.name.localeCompare(b.name, 'de')));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isActive) setLoadError(error instanceof Error ? error.message : 'Die Ziele konnten nicht geladen werden.');
|
||||
})
|
||||
.finally(() => {
|
||||
if (isActive) setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [targetType, token]);
|
||||
|
||||
const filteredTargets = useMemo(() => {
|
||||
if (targetType === 'project') return filterProjects(targets as Project[], search);
|
||||
if (targetType === 'customer') return filterCustomers(targets as Customer[], search);
|
||||
if (targetType === 'plant') return filterPlants(targets as Plant[], search);
|
||||
return [];
|
||||
}, [search, targets, targetType]);
|
||||
|
||||
const close = () => {
|
||||
resetShareIntent();
|
||||
router.replace('/');
|
||||
};
|
||||
|
||||
const confirmCancel = () => {
|
||||
Alert.alert('Upload abbrechen?', 'Die geteilten Dateien werden nicht hochgeladen.', [
|
||||
{ text: 'Weiter auswählen', style: 'cancel' },
|
||||
{ text: 'Abbrechen', style: 'destructive', onPress: close },
|
||||
]);
|
||||
};
|
||||
|
||||
const uploadTo = async (target: UploadTarget) => {
|
||||
if (!token || !targetType || files.length === 0 || isUploading) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadedCount(0);
|
||||
try {
|
||||
for (const [index, file] of files.entries()) {
|
||||
const common = {
|
||||
uri: file.path,
|
||||
filename: file.fileName || `Geteilte Datei ${index + 1}`,
|
||||
mimeType: file.mimeType || undefined,
|
||||
};
|
||||
|
||||
if (targetType === 'project') {
|
||||
await uploadProjectFile(token, { ...common, projectId: target.id });
|
||||
} else if (targetType === 'customer') {
|
||||
await uploadCustomerFile(token, { ...common, customerId: target.id });
|
||||
} else {
|
||||
await uploadPlantFile(token, { ...common, plantId: target.id });
|
||||
}
|
||||
setUploadedCount(index + 1);
|
||||
}
|
||||
|
||||
resetShareIntent();
|
||||
Alert.alert('Upload abgeschlossen', `${files.length} ${files.length === 1 ? 'Datei wurde' : 'Dateien wurden'} zu „${target.name}“ hochgeladen.`, [
|
||||
{ text: 'Fertig', onPress: () => router.replace('/') },
|
||||
]);
|
||||
} catch (error) {
|
||||
Alert.alert('Upload fehlgeschlagen', error instanceof Error ? error.message : 'Die Datei konnte nicht hochgeladen werden.');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea} edges={['bottom']}>
|
||||
<View style={styles.emptyState}>
|
||||
<View style={styles.emptyIcon}>
|
||||
<Ionicons name="document-outline" size={36} color={PRIMARY} />
|
||||
</View>
|
||||
<Text style={styles.emptyTitle}>Keine Dateien gefunden</Text>
|
||||
<Text style={styles.emptyText}>Teile eine Datei oder ein Foto erneut mit FEDEO.</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={close}>
|
||||
<Text style={styles.primaryButtonText}>Zurück zu FEDEO</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea} edges={['bottom']}>
|
||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.fileSummary}>
|
||||
<View style={styles.summaryIcon}>
|
||||
<Ionicons name="documents-outline" size={22} color="#3d7a30" />
|
||||
</View>
|
||||
<View style={styles.flex}>
|
||||
<Text style={styles.summaryTitle}>{files.length} {files.length === 1 ? 'Datei' : 'Dateien'} ausgewählt</Text>
|
||||
<Text style={styles.summaryText} numberOfLines={2}>{files.map((file) => file.fileName).join(', ')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!targetType ? (
|
||||
<>
|
||||
<Text style={styles.heading}>Wohin möchtest du hochladen?</Text>
|
||||
<Text style={styles.description}>Wähle zuerst die Art des Ziels aus.</Text>
|
||||
<View style={styles.typeGrid}>
|
||||
{TARGET_TYPES.map((type) => (
|
||||
<Pressable key={type.key} style={styles.typeCard} onPress={() => setTargetType(type.key)}>
|
||||
<View style={styles.iconCircle}><Ionicons name={type.icon} size={24} color="#3d7a30" /></View>
|
||||
<Text style={styles.typeLabel}>{type.label}</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pressable style={styles.backLink} onPress={() => setTargetType(null)} disabled={isUploading}>
|
||||
<Ionicons name="chevron-back" size={18} color="#3d7a30" />
|
||||
<Text style={styles.backLinkText}>Andere Zielart wählen</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>{selectedType?.label} auswählen</Text>
|
||||
<View style={styles.searchBox}>
|
||||
<Ionicons name="search" size={19} color="#6b7280" />
|
||||
<TextInput
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder={`${selectedType?.plural || 'Ziele'} durchsuchen`}
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
editable={!isUploading}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<ActivityIndicator style={styles.loader} color={PRIMARY} />
|
||||
) : loadError ? (
|
||||
<Text style={styles.errorText}>{loadError}</Text>
|
||||
) : filteredTargets.length === 0 ? (
|
||||
<Text style={styles.emptyListText}>Keine passenden {selectedType?.plural.toLocaleLowerCase('de')} gefunden.</Text>
|
||||
) : (
|
||||
<View style={styles.targetList}>
|
||||
{filteredTargets.map((target) => {
|
||||
const number = 'projectNumber' in target
|
||||
? target.projectNumber
|
||||
: 'customerNumber' in target
|
||||
? target.customerNumber
|
||||
: null;
|
||||
const detail = targetType === 'project'
|
||||
? getActiveProjectPhase(target as Project)
|
||||
: targetType === 'plant'
|
||||
? getPlantCustomerName((target as Plant).customer)
|
||||
: null;
|
||||
return (
|
||||
<Pressable
|
||||
key={target.id}
|
||||
style={({ pressed }) => [styles.targetRow, pressed ? styles.targetRowPressed : null]}
|
||||
onPress={() => void uploadTo(target)}
|
||||
disabled={isUploading}>
|
||||
<View style={styles.flex}>
|
||||
<Text style={styles.targetName}>{target.name}</Text>
|
||||
{number ? <Text style={styles.targetNumber}>Nr.: {String(number)}</Text> : null}
|
||||
{detail ? (
|
||||
<Text style={styles.targetNumber} numberOfLines={1}>
|
||||
{targetType === 'project' ? `Phase: ${detail}` : `Kunde: ${detail}`}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.uploadIcon}>
|
||||
<Ionicons name="cloud-upload-outline" size={21} color="#3d7a30" />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isUploading ? (
|
||||
<View style={styles.uploadOverlay}>
|
||||
<ActivityIndicator color={PRIMARY} />
|
||||
<Text style={styles.uploadText}>Upload läuft: {uploadedCount} von {files.length}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Pressable style={styles.cancelButton} onPress={confirmCancel} disabled={isUploading}>
|
||||
<Text style={styles.cancelButtonText}>Abbrechen</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: { flex: 1, backgroundColor: '#ffffff' },
|
||||
content: { paddingBottom: 28, gap: 14 },
|
||||
flex: { flex: 1 },
|
||||
fileSummary: { flexDirection: 'row', gap: 12, alignItems: 'center', paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: '#d8edcf', backgroundColor: '#eff9ea' },
|
||||
summaryIcon: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: '#ffffff' },
|
||||
summaryTitle: { fontSize: 15, fontWeight: '700', color: '#2f5f24' },
|
||||
summaryText: { marginTop: 2, fontSize: 12, color: '#4f6f47' },
|
||||
heading: { marginTop: 4, paddingHorizontal: 16, fontSize: 20, fontWeight: '700', color: '#111827' },
|
||||
description: { marginTop: -8, paddingHorizontal: 16, fontSize: 14, color: '#6b7280' },
|
||||
typeGrid: { borderTopWidth: 1, borderTopColor: '#e5e7eb' },
|
||||
typeCard: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: '#e5e7eb', backgroundColor: '#ffffff' },
|
||||
iconCircle: { width: 42, height: 42, borderRadius: 21, alignItems: 'center', justifyContent: 'center', backgroundColor: '#eff9ea' },
|
||||
typeLabel: { flex: 1, fontSize: 15, fontWeight: '600', color: '#111827' },
|
||||
backLink: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', marginHorizontal: 12, marginTop: 2, padding: 4 },
|
||||
backLinkText: { fontSize: 14, fontWeight: '600', color: '#3d7a30' },
|
||||
searchBox: { minHeight: 44, marginHorizontal: 16, flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 12, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10, backgroundColor: '#ffffff' },
|
||||
searchInput: { flex: 1, paddingVertical: 10, fontSize: 15, color: '#111827' },
|
||||
loader: { marginVertical: 32 },
|
||||
errorText: { marginHorizontal: 16, padding: 12, borderRadius: 10, backgroundColor: '#fef2f2', color: '#b91c1c' },
|
||||
emptyListText: { paddingVertical: 28, textAlign: 'center', color: '#6b7280' },
|
||||
targetList: { borderTopWidth: 1, borderTopColor: '#e5e7eb', backgroundColor: '#ffffff' },
|
||||
targetRow: { minHeight: 64, flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#e5e7eb', backgroundColor: '#ffffff' },
|
||||
targetRowPressed: { backgroundColor: '#f3f4f6' },
|
||||
targetName: { fontSize: 15, fontWeight: '600', color: '#111827' },
|
||||
targetNumber: { marginTop: 3, fontSize: 13, color: '#6b7280' },
|
||||
uploadIcon: { width: 38, height: 38, borderRadius: 19, alignItems: 'center', justifyContent: 'center', backgroundColor: '#eff9ea' },
|
||||
uploadOverlay: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, marginHorizontal: 16, padding: 14, borderRadius: 10, backgroundColor: '#eff9ea' },
|
||||
uploadText: { fontWeight: '600', color: '#2f5f24' },
|
||||
cancelButton: { alignItems: 'center', padding: 14 },
|
||||
cancelButtonText: { fontSize: 16, fontWeight: '600', color: '#b91c1c' },
|
||||
emptyState: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 28 },
|
||||
emptyIcon: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center', backgroundColor: '#eff9ea' },
|
||||
emptyTitle: { marginTop: 14, fontSize: 20, fontWeight: '700', color: '#111827' },
|
||||
emptyText: { marginTop: 6, textAlign: 'center', color: '#6b7280' },
|
||||
primaryButton: { marginTop: 22, paddingHorizontal: 18, paddingVertical: 13, borderRadius: 10, backgroundColor: PRIMARY },
|
||||
primaryButtonText: { fontSize: 15, fontWeight: '700', color: '#fff' },
|
||||
});
|
||||
755
mobile/package-lock.json
generated
755
mobile/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"expo": "~54.0.35",
|
||||
"expo": "~54.0.36",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-document-picker": "^14.0.8",
|
||||
@@ -38,6 +38,7 @@
|
||||
"expo-notifications": "~0.32.17",
|
||||
"expo-router": "~6.0.24",
|
||||
"expo-secure-store": "^15.0.8",
|
||||
"expo-share-intent": "^5.1.1",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-symbols": "~1.0.8",
|
||||
|
||||
421
mobile/plugins/ios/FEDEOSiriIntents.swift
Normal file
421
mobile/plugins/ios/FEDEOSiriIntents.swift
Normal file
@@ -0,0 +1,421 @@
|
||||
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)",
|
||||
"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"
|
||||
)
|
||||
}
|
||||
}
|
||||
158
mobile/plugins/with-share-intent-multifile.js
Normal file
158
mobile/plugins/with-share-intent-multifile.js
Normal file
@@ -0,0 +1,158 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { withAppDelegate, withInfoPlist, 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)) {
|
||||
throw new Error(`Multi-File-Patch konnte ${label} nicht finden.`);
|
||||
}
|
||||
return source.replace(search, replacement);
|
||||
}
|
||||
|
||||
module.exports = function withShareIntentMultifile(config) {
|
||||
config = withInfoPlist(config, (modConfig) => {
|
||||
modConfig.modResults.INAlternativeAppNames = [
|
||||
{
|
||||
INAlternativeAppName: 'Fedeo',
|
||||
INAlternativeAppNamePronunciationHint: 'Feh-deh-oh',
|
||||
},
|
||||
{
|
||||
INAlternativeAppName: 'FEDEO Aufgaben',
|
||||
INAlternativeAppNamePronunciationHint: 'Feh-deh-oh Aufgaben',
|
||||
},
|
||||
];
|
||||
return modConfig;
|
||||
});
|
||||
|
||||
config = withAppDelegate(config, (modConfig) => {
|
||||
let source = modConfig.modResults.contents;
|
||||
if (!source.includes('import AppIntents')) {
|
||||
source = source.replace('import Expo\n', 'import AppIntents\nimport Expo\n');
|
||||
}
|
||||
if (!source.includes('FEDEOAppShortcuts.updateAppShortcutParameters()')) {
|
||||
source = replaceOnce(
|
||||
source,
|
||||
' ) -> Bool {\n',
|
||||
' ) -> Bool {\n if #available(iOS 16.0, *) {\n FEDEOAppShortcuts.updateAppShortcutParameters()\n }\n',
|
||||
'die Siri-Shortcut-Aktualisierung im AppDelegate'
|
||||
);
|
||||
}
|
||||
modConfig.modResults.contents = source;
|
||||
return 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(
|
||||
modConfig.modRequest.platformProjectRoot,
|
||||
SHARE_EXTENSION_DIRECTORY,
|
||||
'ShareViewController.swift'
|
||||
);
|
||||
|
||||
let source = fs.readFileSync(controllerPath, 'utf8');
|
||||
|
||||
source = replaceOnce(
|
||||
source,
|
||||
' var sharedText: [String] = []\n',
|
||||
' var sharedText: [String] = []\n var processedMediaAttachmentCount = 0\n',
|
||||
'den Attachment-Zähler'
|
||||
);
|
||||
|
||||
const imageCompletion = ` // If this is the last item, save imagesData in userDefaults and redirect to host app
|
||||
if index == (content.attachments?.count)! - 1 {
|
||||
let userDefaults = UserDefaults(suiteName: self.hostAppGroupIdentifier)
|
||||
userDefaults?.set(self.toData(data: self.sharedMedia), forKey: self.sharedKey)
|
||||
userDefaults?.synchronize()
|
||||
self.redirectToHostApp(type: .media)
|
||||
}`;
|
||||
|
||||
source = replaceOnce(
|
||||
source,
|
||||
imageCompletion,
|
||||
' self.finishMediaAttachment(content: content, type: .media)',
|
||||
'den Abschluss der Bildverarbeitung'
|
||||
);
|
||||
|
||||
source = replaceOnce(
|
||||
source,
|
||||
imageCompletion,
|
||||
' self.finishMediaAttachment(content: content, type: .media)',
|
||||
'den Abschluss der Videoverarbeitung'
|
||||
);
|
||||
|
||||
const fileCompletion = ` if index == (content.attachments?.count)! - 1 {
|
||||
let userDefaults = UserDefaults(suiteName: self.hostAppGroupIdentifier)
|
||||
userDefaults?.set(self.toData(data: self.sharedMedia), forKey: self.sharedKey)
|
||||
userDefaults?.synchronize()
|
||||
self.redirectToHostApp(type: .file)
|
||||
}`;
|
||||
|
||||
source = replaceOnce(
|
||||
source,
|
||||
fileCompletion,
|
||||
' self.finishMediaAttachment(content: content, type: .file)',
|
||||
'den Abschluss der Dateiverarbeitung'
|
||||
);
|
||||
|
||||
const redirectMethod = ' private func redirectToHostApp(type: RedirectType) {';
|
||||
const completionMethod = ` private func finishMediaAttachment(content: NSExtensionItem, type: RedirectType) {
|
||||
processedMediaAttachmentCount += 1
|
||||
let expectedMediaAttachmentCount = content.attachments?.filter { attachment in
|
||||
attachment.hasItemConformingToTypeIdentifier(imageContentType)
|
||||
|| attachment.hasItemConformingToTypeIdentifier(videoContentType)
|
||||
|| attachment.hasItemConformingToTypeIdentifier(vcardContentType)
|
||||
|| attachment.hasItemConformingToTypeIdentifier(fileURLType)
|
||||
|| attachment.hasItemConformingToTypeIdentifier(pkpassContentType)
|
||||
|| attachment.hasItemConformingToTypeIdentifier(pdfContentType)
|
||||
}.count ?? 0
|
||||
guard processedMediaAttachmentCount == expectedMediaAttachmentCount else { return }
|
||||
|
||||
let userDefaults = UserDefaults(suiteName: hostAppGroupIdentifier)
|
||||
userDefaults?.set(toData(data: sharedMedia), forKey: sharedKey)
|
||||
userDefaults?.synchronize()
|
||||
redirectToHostApp(type: type)
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
source = replaceOnce(
|
||||
source,
|
||||
redirectMethod,
|
||||
completionMethod + redirectMethod,
|
||||
'die Weiterleitungsmethode'
|
||||
);
|
||||
|
||||
fs.writeFileSync(controllerPath, source);
|
||||
return modConfig;
|
||||
});
|
||||
};
|
||||
63
mobile/src/lib/resource-list-filters.ts
Normal file
63
mobile/src/lib/resource-list-filters.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Customer, Plant, Project } from './api';
|
||||
|
||||
function searchTerms(search: string): string[] {
|
||||
return search.trim().toLocaleLowerCase('de').split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function matchesTerms(values: unknown[], terms: string[]): boolean {
|
||||
if (terms.length === 0) return true;
|
||||
const haystack = values
|
||||
.map((value) => String(value || '').toLocaleLowerCase('de'))
|
||||
.join(' ');
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
}
|
||||
|
||||
export function getActiveProjectPhase(project: Project): string {
|
||||
const explicit = String(project.active_phase || '').trim();
|
||||
if (explicit) return explicit;
|
||||
|
||||
const phases = Array.isArray(project.phases) ? project.phases : [];
|
||||
const active = phases.find((phase: any) => phase?.active);
|
||||
return String(active?.label || '').trim();
|
||||
}
|
||||
|
||||
export function isProjectCompleted(project: Project): boolean {
|
||||
return getActiveProjectPhase(project).toLocaleLowerCase('de') === 'abgeschlossen';
|
||||
}
|
||||
|
||||
export function filterProjects(projects: Project[], search: string, showCompleted = false): Project[] {
|
||||
const terms = searchTerms(search);
|
||||
return projects.filter((project) => {
|
||||
if (!showCompleted && isProjectCompleted(project)) return false;
|
||||
return matchesTerms([
|
||||
project.name,
|
||||
project.projectNumber,
|
||||
project.notes,
|
||||
project.customerRef,
|
||||
project.active_phase,
|
||||
getActiveProjectPhase(project),
|
||||
], terms);
|
||||
});
|
||||
}
|
||||
|
||||
export function filterCustomers(customers: Customer[], search: string, showArchived = false): Customer[] {
|
||||
const terms = searchTerms(search);
|
||||
return customers.filter((customer) => {
|
||||
if (!showArchived && customer.archived) return false;
|
||||
return matchesTerms([customer.name, customer.customerNumber, customer.notes], terms);
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlantCustomerName(raw: Plant['customer']): string | null {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === 'object') return raw.name ? String(raw.name) : null;
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
export function filterPlants(plants: Plant[], search: string, showArchived = false): Plant[] {
|
||||
const terms = searchTerms(search);
|
||||
return plants.filter((plant) => {
|
||||
if (!showArchived && plant.archived) return false;
|
||||
return matchesTerms([plant.name, plant.description, getPlantCustomerName(plant.customer)], terms);
|
||||
});
|
||||
}
|
||||
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