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

This commit is contained in:
2026-08-10 15:22:52 +02:00
18 changed files with 1792 additions and 298 deletions

View File

@@ -177,11 +177,49 @@ export default async function exportRoutes(server: FastifyInstance) {
.from(generatedexports) .from(generatedexports)
.where(eq(generatedexports.tenantId, req.user.tenant_id)) .where(eq(generatedexports.tenantId, req.user.tenant_id))
console.log(data) reply.send(data.map(item => ({
reply.send(data) ...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" })
}
})

View File

@@ -4,7 +4,6 @@ import { s3 } from "../utils/s3"
import { import {
GetObjectCommand GetObjectCommand
} from "@aws-sdk/client-s3" } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import archiver from "archiver" import archiver from "archiver"
import { secrets } from "../utils/secrets" import { secrets } from "../utils/secrets"
import { saveFile } from "../utils/files" 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) const file = await loadSingleFileForRequest(req, id)
if (!file) return reply.code(404).send({ error: "Not found" }) if (!file) return reply.code(404).send({ error: "Not found" })
const url = await getSignedUrl( return { ...file, url: `/api/files/content/${file.id}` }
s3,
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path! }),
{ expiresIn: 900 }
)
return { ...file, url }
} else { } else {
// ------------------------------------------------- // -------------------------------------------------
// MULTIPLE PRESIGNED URLs // 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) const selected = rows.filter(f => ids.includes(f.id) && f.path)
console.log(selected) const output = selected.map(file => ({
...file,
const url = await getSignedUrl( url: `/api/files/content/${file.id}`
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 }
})
)
return { files: output } return { files: output }
} }

View File

@@ -62,13 +62,19 @@ const setupPage = async () => {
setupPage() 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") const a = document.createElement("a")
a.href = row.url a.href = objectUrl
a.download = row.file_path.split("/").pop() a.download = row.file_path.split("/").pop()
document.body.appendChild(a) document.body.appendChild(a)
a.click() a.click()
document.body.removeChild(a) document.body.removeChild(a)
URL.revokeObjectURL(objectUrl)
} }
const showCreateExportModal = ref(false) const showCreateExportModal = ref(false)

View File

@@ -11,7 +11,7 @@
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "software.federspiel.fedeo", "bundleIdentifier": "software.federspiel.fedeo",
"buildNumber": "7", "buildNumber": "11",
"infoPlist": { "infoPlist": {
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.", "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.", "NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
@@ -56,7 +56,21 @@
], ],
"react-native-ble-plx", "react-native-ble-plx",
"expo-notifications", "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": { "experiments": {
"typedRoutes": true, "typedRoutes": true,

View File

@@ -15,6 +15,7 @@ import {
import { router } from 'expo-router'; import { router } from 'expo-router';
import { createProject, Customer, fetchCustomers, fetchPlants, fetchProjects, Plant, Project } from '@/src/lib/api'; 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'; import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350'; const PRIMARY = '#69c350';
@@ -24,20 +25,6 @@ function getProjectLine(project: Project): string {
return project.name; 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() { export default function ProjectsScreen() {
const { token } = useAuth(); const { token } = useAuth();
@@ -64,31 +51,10 @@ export default function ProjectsScreen() {
const [customerSearch, setCustomerSearch] = useState(''); const [customerSearch, setCustomerSearch] = useState('');
const [plantSearch, setPlantSearch] = useState(''); const [plantSearch, setPlantSearch] = useState('');
const filteredProjects = useMemo(() => { const filteredProjects = useMemo(
const terms = search () => filterProjects(projects, search, showArchived),
.trim() [projects, search, showArchived]
.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 selectedCustomerLabel = useMemo(() => { const selectedCustomerLabel = useMemo(() => {
if (!selectedCustomerId) return 'Kunde auswählen (optional)'; 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}`)}> <Pressable key={String(project.id)} style={styles.row} onPress={() => router.push(`/project/${project.id}`)}>
<View style={styles.rowHeader}> <View style={styles.rowHeader}>
<Text style={styles.rowTitle} numberOfLines={1}>{getProjectLine(project)}</Text> <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> </View>
{getActivePhaseLabel(project) ? ( {getActiveProjectPhase(project) ? (
<Text style={styles.phaseText} numberOfLines={1}>Phase: {getActivePhaseLabel(project)}</Text> <Text style={styles.phaseText} numberOfLines={1}>Phase: {getActiveProjectPhase(project)}</Text>
) : null} ) : null}
{project.notes ? <Text style={styles.rowSubtitle} numberOfLines={1}>{String(project.notes)}</Text> : null} {project.notes ? <Text style={styles.rowSubtitle} numberOfLines={1}>{String(project.notes)}</Text> : null}
</Pressable> </Pressable>

View 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 '/';
}
}

View File

@@ -1,17 +1,51 @@
import { Stack } from 'expo-router'; import { Href, Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { ShareIntentProvider, useShareIntentContext } from 'expo-share-intent';
import { useEffect, useRef } from 'react';
import 'react-native-reanimated'; 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() { export default function RootLayout() {
return ( return (
<AuthProvider> <ShareIntentProvider>
<Stack> <AuthProvider>
<ShareIntentRouter />
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} /> <Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ title: 'Login', headerBackVisible: 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="tenant-select" options={{ title: 'Tenant auswählen', headerBackVisible: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: 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 <Stack.Screen
name="project/[id]" name="project/[id]"
options={{ options={{
@@ -102,8 +136,9 @@ export default function RootLayout() {
headerTintColor: '#111827', headerTintColor: '#111827',
}} }}
/> />
</Stack> </Stack>
<StatusBar style="dark" /> <StatusBar style="dark" />
</AuthProvider> </AuthProvider>
</ShareIntentProvider>
); );
} }

View File

@@ -15,6 +15,7 @@ import {
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { createCustomer, Customer, fetchCustomers } from '@/src/lib/api'; import { createCustomer, Customer, fetchCustomers } from '@/src/lib/api';
import { filterCustomers } from '@/src/lib/resource-list-filters';
import { useAuth } from '@/src/providers/auth-provider'; import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350'; const PRIMARY = '#69c350';
@@ -37,20 +38,10 @@ export default function CustomersScreen() {
const [numberInput, setNumberInput] = useState(''); const [numberInput, setNumberInput] = useState('');
const [notesInput, setNotesInput] = useState(''); const [notesInput, setNotesInput] = useState('');
const filtered = useMemo(() => { const filtered = useMemo(
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); () => filterCustomers(customers, search, showArchived),
[customers, search, showArchived]
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 load = useCallback(async (showSpinner = true) => { const load = useCallback(async (showSpinner = true) => {
if (!token) return; if (!token) return;

View File

@@ -15,16 +15,11 @@ import {
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { createPlant, Customer, fetchCustomers, fetchPlants, Plant } from '@/src/lib/api'; 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'; import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350'; 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() { export default function PlantsScreen() {
const { token } = useAuth(); const { token } = useAuth();
const router = useRouter(); const router = useRouter();
@@ -47,20 +42,10 @@ export default function PlantsScreen() {
const [pickerMode, setPickerMode] = useState<'customer' | null>(null); const [pickerMode, setPickerMode] = useState<'customer' | null>(null);
const [customerSearch, setCustomerSearch] = useState(''); const [customerSearch, setCustomerSearch] = useState('');
const filtered = useMemo(() => { const filtered = useMemo(
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); () => filterPlants(plants, search, showArchived),
[plants, search, showArchived]
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 filteredCustomerOptions = useMemo(() => { const filteredCustomerOptions = useMemo(() => {
const terms = customerSearch.trim().toLowerCase().split(/\s+/).filter(Boolean); 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> <Text style={styles.rowTitle} numberOfLines={1}>{plant.name}</Text>
{plant.archived ? <Text style={styles.badge}>Abgeschlossen</Text> : null} {plant.archived ? <Text style={styles.badge}>Abgeschlossen</Text> : null}
</View> </View>
{getCustomerName(plant.customer) ? ( {getPlantCustomerName(plant.customer) ? (
<Text style={styles.rowSubtitle} numberOfLines={1}>Kunde: {getCustomerName(plant.customer)}</Text> <Text style={styles.rowSubtitle} numberOfLines={1}>Kunde: {getPlantCustomerName(plant.customer)}</Text>
) : null} ) : null}
{plant.description ? <Text style={styles.rowSubtitle} numberOfLines={1}>{String(plant.description)}</Text> : null} {plant.description ? <Text style={styles.rowSubtitle} numberOfLines={1}>{String(plant.description)}</Text> : null}
</Pressable> </Pressable>

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,

306
mobile/app/share-upload.tsx Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,7 @@
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"expo": "~54.0.35", "expo": "~54.0.36",
"expo-camera": "~17.0.10", "expo-camera": "~17.0.10",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-document-picker": "^14.0.8", "expo-document-picker": "^14.0.8",
@@ -38,6 +38,7 @@
"expo-notifications": "~0.32.17", "expo-notifications": "~0.32.17",
"expo-router": "~6.0.24", "expo-router": "~6.0.24",
"expo-secure-store": "^15.0.8", "expo-secure-store": "^15.0.8",
"expo-share-intent": "^5.1.1",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8", "expo-symbols": "~1.0.8",

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

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

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

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),
]); ]);
} }
} }