KI-AGENT: iOS Share Target mit Upload-Strecke ergänzen
This commit is contained in:
@@ -56,7 +56,20 @@
|
||||
],
|
||||
"react-native-ble-plx",
|
||||
"expo-notifications",
|
||||
"expo-web-browser"
|
||||
"expo-web-browser",
|
||||
[
|
||||
"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,
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
275
mobile/app/share-upload.tsx
Normal file
275
mobile/app/share-upload.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
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';
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
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)
|
||||
: targetType === 'customer'
|
||||
? fetchCustomers(token)
|
||||
: fetchPlants(token);
|
||||
|
||||
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(() => {
|
||||
const term = search.trim().toLocaleLowerCase('de');
|
||||
if (!term) return targets;
|
||||
return targets.filter((target) => {
|
||||
const number = 'projectNumber' in target
|
||||
? target.projectNumber
|
||||
: 'customerNumber' in target
|
||||
? target.customerNumber
|
||||
: null;
|
||||
return `${target.name} ${number || ''}`.toLocaleLowerCase('de').includes(term);
|
||||
});
|
||||
}, [search, targets]);
|
||||
|
||||
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}>
|
||||
<Ionicons name="document-outline" size={44} color="#9ca3af" />
|
||||
<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}>
|
||||
<Ionicons name="documents-outline" size={24} color="#2563eb" />
|
||||
<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={26} color="#2563eb" /></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="#2563eb" />
|
||||
<Text style={styles.backLinkText}>Andere Zielart wählen</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>{selectedType?.label} auswählen</Text>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder={`${selectedType?.plural || 'Ziele'} durchsuchen`}
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
editable={!isUploading}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<ActivityIndicator style={styles.loader} color="#2563eb" />
|
||||
) : 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;
|
||||
return (
|
||||
<Pressable key={target.id} style={styles.targetRow} onPress={() => void uploadTo(target)} disabled={isUploading}>
|
||||
<View style={styles.flex}>
|
||||
<Text style={styles.targetName}>{target.name}</Text>
|
||||
{number ? <Text style={styles.targetNumber}>{String(number)}</Text> : null}
|
||||
</View>
|
||||
<Ionicons name="cloud-upload-outline" size={22} color="#2563eb" />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isUploading ? (
|
||||
<View style={styles.uploadOverlay}>
|
||||
<ActivityIndicator color="#2563eb" />
|
||||
<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: '#f9fafb' },
|
||||
content: { padding: 20, paddingBottom: 36, gap: 16 },
|
||||
flex: { flex: 1 },
|
||||
fileSummary: { flexDirection: 'row', gap: 12, alignItems: 'center', padding: 16, borderRadius: 14, backgroundColor: '#eff6ff' },
|
||||
summaryTitle: { fontSize: 16, fontWeight: '700', color: '#1e3a8a' },
|
||||
summaryText: { marginTop: 3, fontSize: 13, color: '#3b4f78' },
|
||||
heading: { marginTop: 8, fontSize: 23, fontWeight: '800', color: '#111827' },
|
||||
description: { marginTop: -10, fontSize: 15, color: '#6b7280' },
|
||||
typeGrid: { gap: 12 },
|
||||
typeCard: { flexDirection: 'row', alignItems: 'center', gap: 14, padding: 16, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 14, backgroundColor: '#fff' },
|
||||
iconCircle: { width: 46, height: 46, borderRadius: 23, alignItems: 'center', justifyContent: 'center', backgroundColor: '#eff6ff' },
|
||||
typeLabel: { flex: 1, fontSize: 17, fontWeight: '700', color: '#111827' },
|
||||
backLink: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', marginTop: 2 },
|
||||
backLinkText: { fontSize: 14, fontWeight: '600', color: '#2563eb' },
|
||||
searchInput: { height: 48, paddingHorizontal: 14, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 12, backgroundColor: '#fff', fontSize: 16, color: '#111827' },
|
||||
loader: { marginVertical: 32 },
|
||||
errorText: { padding: 16, borderRadius: 12, backgroundColor: '#fef2f2', color: '#b91c1c' },
|
||||
emptyListText: { paddingVertical: 28, textAlign: 'center', color: '#6b7280' },
|
||||
targetList: { overflow: 'hidden', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 14, backgroundColor: '#fff' },
|
||||
targetRow: { minHeight: 64, flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#d1d5db' },
|
||||
targetName: { fontSize: 16, fontWeight: '600', color: '#111827' },
|
||||
targetNumber: { marginTop: 3, fontSize: 13, color: '#6b7280' },
|
||||
uploadOverlay: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, padding: 14, borderRadius: 12, backgroundColor: '#eff6ff' },
|
||||
uploadText: { fontWeight: '600', color: '#1e3a8a' },
|
||||
cancelButton: { alignItems: 'center', padding: 14 },
|
||||
cancelButtonText: { fontSize: 16, fontWeight: '600', color: '#b91c1c' },
|
||||
emptyState: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 28 },
|
||||
emptyTitle: { marginTop: 14, fontSize: 20, fontWeight: '700', color: '#111827' },
|
||||
emptyText: { marginTop: 6, textAlign: 'center', color: '#6b7280' },
|
||||
primaryButton: { marginTop: 22, paddingHorizontal: 18, paddingVertical: 13, borderRadius: 11, backgroundColor: '#2563eb' },
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user