Zum Hauptinhalt springen

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

TechnologieVersionVerwendung
Ionic8UI-Framework
React19Component Library
Capacitor7Native Bridge
TypeScript5.xTypsystem
Zustand5.xState Management
TanStack Query5.xServer State
Vite6.xBuild 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');
}