Mobile App (Ionic)
Die Mobile App ist für Sprachmittler konzipiert und ermöglicht die Verwaltung von Aufträgen, Check-In/Out und Rechnungsstellung.
Technologie-Stack
| Technologie | Version | Verwendung |
|---|---|---|
| Ionic | 8 | UI-Framework |
| React | 19 | Component Library |
| Capacitor | 7 | Native Bridge |
| TypeScript | 5.x | Typsystem |
| Zustand | 5.x | State Management |
| TanStack Query | 5.x | Server State |
| Vite | 6.x | Build Tool |
Projektstruktur
apps/mobile-ionic/
├── src/
│ ├── App.tsx # Hauptkomponente mit Routing
│ ├── main.tsx # React-Einstiegspunkt
│ ├── components/ # Wiederverwendbare Komponenten
│ │ ├── ProtectedRoute.tsx
│ │ └── AuthRedirect.tsx
│ ├── pages/ # Seiten-Komponenten
│ │ ├── Tab1.tsx # Dashboard
│ │ ├── Tab2.tsx # Aufträge
│ │ └── Tab3.tsx # Profil
│ ├── stores/ # Zustand Stores
│ │ ├── userStore.ts
│ │ └── globalStore.ts
│ ├── contexts/ # React Contexts
│ │ └── OrdersContext.tsx
│ ├── queries/ # TanStack Query
│ │ └── queryKeys.ts
│ ├── hooks/ # Custom Hooks
│ │ └── useAuth.ts
│ └── lib/ # Utilities
│ ├── api.ts # API Client
│ ├── config.ts # Konfiguration
│ └── queryClient.ts # Query Client Setup
├── capacitor.config.ts # Capacitor Konfiguration
└── vite.config.ts # Vite Konfiguration
Routing
Die App verwendet Ionic React Router mit Tab-basierter Navigation.
Route-Definition (App.tsx)
<IonReactRouter>
<IonRouterOutlet>
{/* Public Routes */}
<Route path="/login">
<AuthRedirect><Login /></AuthRedirect>
</Route>
<Route path="/forgot-password">
<AuthRedirect><ForgotPassword /></AuthRedirect>
</Route>
{/* Protected Routes */}
<Route path="/edit-personal-info">
<ProtectedRoute><EditPersonalInfo /></ProtectedRoute>
</Route>
{/* ... weitere Settings-Routes */}
{/* Tab Navigation */}
<Route path="/tabs">
<ProtectedRoute>
<OrdersProvider>
<IonTabs>
<IonRouterOutlet>
<Route path="/tabs/dashboard" component={Tab1} />
<Route path="/tabs/orders" component={Tab2} />
<Route path="/tabs/orders/details" component={OrderDetails} />
<Route path="/tabs/order-requests/details" component={OrderRequestDetails} />
<Route path="/tabs/profile" component={Tab3} />
</IonRouterOutlet>
<IonTabBar slot="bottom">
<IonTabButton tab="tab1" href="/tabs/dashboard">
<IonIcon icon={home} />
<IonLabel>Dashboard</IonLabel>
</IonTabButton>
<IonTabButton tab="tab2" href="/tabs/orders">
<IonIcon icon={list} />
<IonLabel>Aufträge</IonLabel>
</IonTabButton>
<IonTabButton tab="tab3" href="/tabs/profile">
<IonIcon icon={person} />
<IonLabel>Profil</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
</OrdersProvider>
</ProtectedRoute>
</Route>
{/* Root Redirect */}
<Route exact path="/">
<Redirect to={isAuthenticated ? '/tabs/dashboard' : '/login'} />
</Route>
</IonRouterOutlet>
</IonReactRouter>
State Management
Zustand Store (User)
File: userStore.ts
interface UserStore {
user: User | null;
isLoading: boolean;
error: string | null;
hasPendingLegalDocuments: boolean;
loadUserProfile: () => Promise<void>;
forceReloadUserProfile: () => Promise<void>;
updateUser: (updates: Partial<User>) => void;
clearUser: () => void;
setHasPendingLegalDocuments: (value: boolean) => void;
}
export const useUserStore = create<UserStore>((set, get) => ({
user: null,
isLoading: false,
error: null,
hasPendingLegalDocuments: false,
loadUserProfile: async () => {
if (get().isLoading) return;
set({ isLoading: true, error: null });
try {
const token = await storage.get('auth_token');
if (!token) {
set({ user: null, isLoading: false });
return;
}
const profile = await api.getProfile();
set({
user: profile,
isLoading: false,
hasPendingLegalDocuments: profile.hasPendingLegalDocuments ?? false,
});
} catch (error) {
set({ error: 'Failed to load profile', isLoading: false });
}
},
clearUser: () => set({
user: null,
error: null,
hasPendingLegalDocuments: false,
}),
// ...weitere Methoden
}));
TanStack Query
File: queryClient.ts
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 Minuten
gcTime: 24 * 60 * 60 * 1000, // 24 Stunden
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
refetchOnWindowFocus: false, // Mobile-optimiert
refetchOnReconnect: true,
},
},
});
Query Keys Factory
File: queryKeys.ts
export const orderKeys = {
all: ['orders'] as const,
lists: () => [...orderKeys.all, 'list'] as const,
list: (filters: OrderFilters) => [...orderKeys.lists(), filters] as const,
details: () => [...orderKeys.all, 'detail'] as const,
detail: (id: string) => [...orderKeys.details(), id] as const,
checkStatus: (id: string) => [...orderKeys.detail(id), 'checkStatus'] as const,
invoices: (id: string) => [...orderKeys.detail(id), 'invoices'] as const,
additionalCosts: (id: string) => [...orderKeys.detail(id), 'additionalCosts'] as const,
};
export const orderRequestKeys = {
all: ['orderRequests'] as const,
lists: () => [...orderRequestKeys.all, 'list'] as const,
list: (filters: OrderRequestFilters) => [...orderRequestKeys.lists(), filters] as const,
details: () => [...orderRequestKeys.all, 'detail'] as const,
detail: (id: string) => [...orderRequestKeys.details(), id] as const,
};
export const userKeys = {
all: ['user'] as const,
profile: () => [...userKeys.all, 'profile'] as const,
notificationPreferences: () => [...userKeys.all, 'notificationPreferences'] as const,
categoryPreferences: () => [...userKeys.all, 'categoryPreferences'] as const,
};
OrdersContext
File: OrdersContext.tsx
Kombiniert TanStack Query mit lokalen optimistischen Updates:
interface OrdersContextType {
orders: Order[];
isLoading: boolean;
error: string | null;
loadOrders: () => void;
refreshOrders: () => void;
updateOrder: (id: string, updates: Partial<Order>) => void;
addOrder: (order: Order) => void;
removeOrder: (id: string) => void;
getOrderById: (id: string) => Order | undefined;
getTodaysOrders: () => Order[];
}
export function OrdersProvider({ children }: { children: React.ReactNode }) {
const { data: queryOrders, isLoading, error, refetch } = useOrdersQuery();
const [optimisticOrders, setOptimisticOrders] = useState<Order[] | null>(null);
// Optimistische Updates
const updateOrder = useCallback((id: string, updates: Partial<Order>) => {
setOptimisticOrders(prev => {
const base = prev ?? queryOrders ?? [];
return base.map(order =>
order.id === id ? { ...order, ...updates } : order
);
});
}, [queryOrders]);
// Event-Bus Integration
useEffect(() => {
const unsubscribe = eventBus.on('ORDER_STATUS_CHANGED', ({ orderId, newState }) => {
updateOrder(orderId, { state: newState });
});
return unsubscribe;
}, [updateOrder]);
// App Lifecycle
useEffect(() => {
const listener = App.addListener('appStateChange', ({ isActive }) => {
if (isActive) {
refetch();
}
});
return () => { listener.remove(); };
}, [refetch]);
const value = {
orders: optimisticOrders ?? queryOrders ?? [],
isLoading,
error: error?.message ?? null,
loadOrders: () => refetch(),
refreshOrders: () => refetch(),
updateOrder,
// ...weitere Methoden
};
return (
<OrdersContext.Provider value={value}>
{children}
</OrdersContext.Provider>
);
}
API Client
File: api.ts
Der API Client ist als Singleton implementiert mit Token-Management.
Token-Speicherung
// Capacitor Preferences (nativ)
import { Preferences } from '@capacitor/preferences';
async loadToken(): Promise<void> {
try {
// Versuche native Storage
const { value } = await Preferences.get({ key: 'auth_token' });
if (value) {
this.token = value;
return;
}
} catch {
// Fallback: localStorage (Web)
}
const webToken = localStorage.getItem('auth_token');
if (webToken) {
this.token = webToken;
}
}
async saveToken(token: string): Promise<void> {
this.token = token;
try {
await Preferences.set({ key: 'auth_token', value: token });
} catch {
// Fallback
}
localStorage.setItem('auth_token', token);
}
Request-Handler
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
...(this.token && { 'Authorization': `Bearer ${this.token}` }),
...options.headers,
},
});
if (response.status === 401) {
await this.clearToken();
// Redirect to login
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Request failed');
}
return response.json();
}
API-Methoden
// Auth
async login(email: string, password: string): Promise<AuthResponse>;
async logout(): Promise<void>;
async getProfile(): Promise<User>;
async updateProfile(data: Partial<User>): Promise<User>;
// Orders
async getOrders(filters?: OrderFilters): Promise<Order[]>;
async getOrder(id: string): Promise<Order>;
async checkInOrder(id: string): Promise<Order>;
async checkOutOrder(id: string): Promise<Order>;
// Order Requests
async getOrderRequests(): Promise<OrderRequest[]>;
async acceptOrderRequest(id: string): Promise<void>;
async rejectOrderRequest(id: string, reason: string): Promise<void>;
// Invoices
async createInvoice(orderId: string): Promise<Invoice>;
async submitInvoiceForApproval(invoiceId: string): Promise<Invoice>;
// Push Notifications
async registerDevice(playerId: string, platform: string): Promise<void>;
async unregisterDevice(): Promise<void>;
Authentication
File: useAuth.ts
export function useAuth() {
const { user, loadUserProfile, clearUser } = useUserStore();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const checkAuthStatus = useCallback(async (forceApiCheck = false) => {
setIsLoading(true);
try {
await api.ensureInitialized();
const storedUser = await storage.get('user');
const token = await storage.get('auth_token');
if (!token) {
setIsAuthenticated(false);
setIsLoading(false);
return;
}
if (storedUser && !forceApiCheck) {
// Cached User verwenden
useUserStore.getState().updateUser(storedUser);
setIsAuthenticated(true);
} else {
// Frischen User laden
await loadUserProfile();
setIsAuthenticated(true);
}
} catch (error) {
if (error.status === 401) {
await clearAuthData();
setIsAuthenticated(false);
}
} finally {
setIsLoading(false);
}
}, [loadUserProfile]);
const login = useCallback(async (email: string, password: string) => {
const response = await api.login(email, password);
await storage.set('auth_token', response.token);
await storage.set('user', response.user);
setIsAuthenticated(true);
return response;
}, []);
const logout = useCallback(async () => {
await api.logout();
await clearAuthData();
setIsAuthenticated(false);
clearUser();
}, [clearUser]);
return {
user,
isAuthenticated,
isLoading,
checkAuthStatus,
login,
logout,
};
}
Capacitor-Integration
Push Notifications
File: capacitor.config.ts
const config: CapacitorConfig = {
appId: 'com.standpunkt.sprachbruecke',
appName: 'Sprachbrücke',
webDir: 'dist',
plugins: {
PushNotifications: {
presentationOptions: ['badge', 'sound', 'alert'],
},
SplashScreen: {
launchShowDuration: 2000,
backgroundColor: '#D91D5C',
},
StatusBar: {
backgroundColor: '#94C11C',
},
Keyboard: {
resize: 'body',
style: 'dark',
},
},
};
Push Service
// services/pushService.ts
import { PushNotifications } from '@capacitor/push-notifications';
import OneSignal from 'onesignal-cordova-plugin';
export const pushService = {
async initialize() {
// OneSignal initialisieren
OneSignal.setAppId(config.oneSignalAppId);
// Listener für Notifications
OneSignal.setNotificationOpenedHandler((notification) => {
const data = notification.notification.additionalData;
if (data?.orderId) {
// Deep Link zu Order Details
window.location.href = `/tabs/orders/details?id=${data.orderId}`;
}
});
// Player ID an Backend senden
OneSignal.getDeviceState((state) => {
if (state.userId) {
api.registerDevice(state.userId, Capacitor.getPlatform());
}
});
},
async requestPermission() {
const result = await PushNotifications.requestPermissions();
return result.receive === 'granted';
},
};
App Lifecycle
// In App.tsx
useEffect(() => {
// StatusBar-Farbe basierend auf Auth-Status
if (isAuthenticated) {
StatusBar.setBackgroundColor({ color: '#94C11C' }); // Grün
} else {
StatusBar.setBackgroundColor({ color: '#D91D5C' }); // Pink
}
}, [isAuthenticated]);
useEffect(() => {
// App-State Listener
const listener = App.addListener('appStateChange', ({ isActive }) => {
if (isActive && isAuthenticated) {
// Daten aktualisieren wenn App in Vordergrund
queryClient.invalidateQueries();
}
});
return () => { listener.remove(); };
}, [isAuthenticated]);
Build & Deployment
# Entwicklung
ionic serve
# Build
ionic build
# Native Sync
cap sync
# iOS
cap run ios # Simulator
cap open ios # Xcode öffnen
# Android
cap run android # Emulator
cap open android # Android Studio öffnen
# Production Build
ionic build --prod
cap sync --prod
Environment-Konfiguration
File: config.ts
export const config = {
apiUrl: import.meta.env.VITE_API_URL || 'https://api.sprachbruecke.org',
oneSignalAppId: import.meta.env.VITE_ONESIGNAL_APP_ID,
isProduction: import.meta.env.PROD,
isDevelopment: import.meta.env.DEV,
};
// Validierung beim Start
if (!config.oneSignalAppId && config.isProduction) {
console.warn('OneSignal App ID not configured');
}