diff --git a/mobile/src/App.tsx b/mobile/src/App.tsx
new file mode 100644
index 0000000..00ff3f4
--- /dev/null
+++ b/mobile/src/App.tsx
@@ -0,0 +1,74 @@
+import React, { useEffect } from 'react';
+import { Provider as ReduxProvider, useDispatch } from 'react-redux';
+import { PaperProvider } from 'react-native-paper';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { SafeAreaProvider } from 'react-native-safe-area-context';
+import NetInfo from '@react-native-community/netinfo';
+import { Alert } from 'react-native';
+
+import { store } from './store';
+import { theme } from './theme';
+import { queryClient } from './lib/queryClient';
+import { RootNavigator } from './navigation/RootNavigator';
+import { tokenStorage, userStorage } from './lib/storage';
+import { setUser, setLoading } from './store/slices/authSlice';
+import { processOfflineQueue } from './hooks/useOfflineDraft';
+import { proposalsApi, CreateProposalRequest } from './lib/api/proposals';
+
+function AuthBootstrap({ children }: { children: React.ReactNode }) {
+ const dispatch = useDispatch();
+
+ useEffect(() => {
+ (async () => {
+ try {
+ const tokens = await tokenStorage.get();
+ const user = await userStorage.get();
+
+ if (tokens && user && new Date(tokens.expiresAt) > new Date()) {
+ dispatch(setUser(user));
+ } else {
+ if (tokens) await tokenStorage.clear();
+ dispatch(setLoading(false));
+ }
+ } catch {
+ dispatch(setLoading(false));
+ }
+ })();
+ }, [dispatch]);
+
+ useEffect(() => {
+ const unsubscribe = NetInfo.addEventListener((state) => {
+ if (state.isConnected) {
+ processOfflineQueue((data) =>
+ proposalsApi.create(data as CreateProposalRequest),
+ ).then((count) => {
+ if (count > 0) {
+ Alert.alert(
+ 'Synced',
+ `${count} offline proposal${count > 1 ? 's' : ''} submitted.`,
+ );
+ }
+ });
+ }
+ });
+ return unsubscribe;
+ }, []);
+
+ return <>{children}>;
+}
+
+export default function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/mobile/src/config.ts b/mobile/src/config.ts
new file mode 100644
index 0000000..2f6ed0a
--- /dev/null
+++ b/mobile/src/config.ts
@@ -0,0 +1,12 @@
+const Config = {
+ API_URL: __DEV__
+ ? 'http://localhost:5000/api'
+ : 'https://api.proposals.seahaven.com/api',
+ COGNITO_DOMAIN: 'proposal-system.auth.us-east-1.amazoncognito.com',
+ COGNITO_CLIENT_ID: '',
+ COGNITO_USER_POOL_ID: '',
+ COGNITO_REDIRECT_URI: 'com.seahavenind.proposals://auth/callback',
+ COGNITO_SCOPES: ['openid', 'email', 'profile'],
+};
+
+export default Config;
diff --git a/mobile/src/constants/index.ts b/mobile/src/constants/index.ts
new file mode 100644
index 0000000..1082a10
--- /dev/null
+++ b/mobile/src/constants/index.ts
@@ -0,0 +1,33 @@
+export const CACHE_TIME_SHORT = 2 * 60 * 1000;
+export const CACHE_TIME_MEDIUM = 5 * 60 * 1000;
+export const CACHE_TIME_LONG = 10 * 60 * 1000;
+
+export const STALE_TIME_SHORT = 1 * 60 * 1000;
+export const STALE_TIME_MEDIUM = 5 * 60 * 1000;
+export const STALE_TIME_LONG = 10 * 60 * 1000;
+
+export const DEFAULT_PAGE_SIZE = 12;
+export const DEFAULT_PAGE = 1;
+
+export const DEBOUNCE_SEARCH = 600;
+
+export const STORAGE_KEY_USER = 'proposal_system_user';
+export const KEYCHAIN_SERVICE = 'com.seahavenind.proposals';
+
+export const PROPOSAL_STATUSES = [
+ 'Draft',
+ 'InReview',
+ 'Approved',
+ 'Sent',
+ 'Revised',
+] as const;
+
+export const SERVICE_CATEGORIES = [
+ 'HVAC',
+ 'Plumbing',
+ 'Electrical',
+ 'General',
+ 'Renovation',
+] as const;
+
+export const PRIORITIES = ['Standard', 'Urgent', 'Emergency'] as const;
diff --git a/mobile/src/constants/queryKeys.ts b/mobile/src/constants/queryKeys.ts
new file mode 100644
index 0000000..08cfb08
--- /dev/null
+++ b/mobile/src/constants/queryKeys.ts
@@ -0,0 +1,9 @@
+export const QUERY_KEYS = {
+ proposals: 'proposals',
+ proposal: 'proposal',
+ proposalLineItems: 'proposalLineItems',
+ customers: 'customers',
+ users: 'users',
+ dashboard: 'dashboard',
+ auditTrail: 'auditTrail',
+} as const;
diff --git a/mobile/src/hooks/useAuth.ts b/mobile/src/hooks/useAuth.ts
new file mode 100644
index 0000000..40e8dc5
--- /dev/null
+++ b/mobile/src/hooks/useAuth.ts
@@ -0,0 +1,41 @@
+import { useCallback } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+import { Alert } from 'react-native';
+import {
+ selectUser,
+ selectIsAuthenticated,
+ selectAuthLoading,
+ selectAuthError,
+ logout as logoutAction,
+} from '../store/slices/authSlice';
+import { authApi } from '../lib/api/auth';
+import type { RootState } from '../store';
+
+export function useAuth() {
+ const dispatch = useDispatch();
+
+ const user = useSelector((state: RootState) => selectUser(state));
+ const isAuthenticated = useSelector((state: RootState) =>
+ selectIsAuthenticated(state),
+ );
+ const loading = useSelector((state: RootState) => selectAuthLoading(state));
+ const error = useSelector((state: RootState) => selectAuthError(state));
+
+ const logout = useCallback(async () => {
+ try {
+ await authApi.logout();
+ } catch {
+ // Best-effort
+ }
+ dispatch(logoutAction());
+ Alert.alert('Signed Out', 'You have been logged out.');
+ }, [dispatch]);
+
+ return {
+ user,
+ isAuthenticated,
+ loading,
+ error,
+ logout,
+ };
+}
diff --git a/mobile/src/hooks/useOfflineDraft.ts b/mobile/src/hooks/useOfflineDraft.ts
new file mode 100644
index 0000000..d7519f2
--- /dev/null
+++ b/mobile/src/hooks/useOfflineDraft.ts
@@ -0,0 +1,100 @@
+import { useEffect, useState, useCallback, useRef } from 'react';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import NetInfo from '@react-native-community/netinfo';
+
+const DRAFT_KEY = 'proposal_draft';
+const OFFLINE_QUEUE_KEY = 'proposal_offline_queue';
+
+export function useOfflineDraft>(
+ initialData: T,
+) {
+ const [draft, setDraft] = useState(initialData);
+ const [isLoaded, setIsLoaded] = useState(false);
+ const saveTimeout = useRef>();
+
+ useEffect(() => {
+ AsyncStorage.getItem(DRAFT_KEY).then((stored) => {
+ if (stored) {
+ try {
+ setDraft(JSON.parse(stored));
+ } catch {
+ // Corrupted draft, use initial data
+ }
+ }
+ setIsLoaded(true);
+ });
+ }, []);
+
+ const updateDraft = useCallback(
+ (updates: Partial) => {
+ setDraft((prev) => {
+ const next = { ...prev, ...updates };
+ if (saveTimeout.current) clearTimeout(saveTimeout.current);
+ saveTimeout.current = setTimeout(() => {
+ AsyncStorage.setItem(DRAFT_KEY, JSON.stringify(next));
+ }, 1000);
+ return next;
+ });
+ },
+ [],
+ );
+
+ const clearDraft = useCallback(async () => {
+ if (saveTimeout.current) clearTimeout(saveTimeout.current);
+ await AsyncStorage.removeItem(DRAFT_KEY);
+ setDraft(initialData);
+ }, [initialData]);
+
+ return { draft, updateDraft, clearDraft, isLoaded };
+}
+
+interface QueuedSubmission {
+ data: unknown;
+ timestamp: string;
+}
+
+export async function queueOfflineSubmission(data: unknown): Promise {
+ const stored = await AsyncStorage.getItem(OFFLINE_QUEUE_KEY);
+ const queue: QueuedSubmission[] = stored ? JSON.parse(stored) : [];
+ queue.push({ data, timestamp: new Date().toISOString() });
+ await AsyncStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
+}
+
+export async function processOfflineQueue(
+ submitFn: (data: unknown) => Promise,
+): Promise {
+ const state = await NetInfo.fetch();
+ if (!state.isConnected) return 0;
+
+ const stored = await AsyncStorage.getItem(OFFLINE_QUEUE_KEY);
+ if (!stored) return 0;
+
+ const queue: QueuedSubmission[] = JSON.parse(stored);
+ let processed = 0;
+
+ for (const item of queue) {
+ try {
+ await submitFn(item.data);
+ processed++;
+ } catch {
+ break;
+ }
+ }
+
+ if (processed === queue.length) {
+ await AsyncStorage.removeItem(OFFLINE_QUEUE_KEY);
+ } else if (processed > 0) {
+ await AsyncStorage.setItem(
+ OFFLINE_QUEUE_KEY,
+ JSON.stringify(queue.slice(processed)),
+ );
+ }
+
+ return processed;
+}
+
+export async function getOfflineQueueCount(): Promise {
+ const stored = await AsyncStorage.getItem(OFFLINE_QUEUE_KEY);
+ if (!stored) return 0;
+ return JSON.parse(stored).length;
+}
diff --git a/mobile/src/hooks/usePaginatedList.ts b/mobile/src/hooks/usePaginatedList.ts
new file mode 100644
index 0000000..8d439df
--- /dev/null
+++ b/mobile/src/hooks/usePaginatedList.ts
@@ -0,0 +1,73 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../constants';
+
+interface PaginatedResult {
+ items: T[];
+ totalCount: number;
+}
+
+type FetchFn = (
+ params: Record,
+) => Promise>;
+
+export function usePaginatedList(
+ fetchFn: FetchFn,
+ extraParams: Record = {},
+) {
+ const [rows, setRows] = useState([]);
+ const [search, setSearch] = useState('');
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+ const [page, setPage] = useState(DEFAULT_PAGE);
+ const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
+ const [totalCount, setTotalCount] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [err, setErr] = useState('');
+ const debounceRef = useRef | null>(null);
+
+ const extraKey = JSON.stringify(extraParams);
+
+ useEffect(() => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {
+ setDebouncedSearch(search);
+ setPage(1);
+ }, DEBOUNCE_SEARCH);
+ return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ };
+ }, [search]);
+
+ const reload = useCallback(() => {
+ setLoading(true);
+ setErr('');
+ fetchFn({ search: debouncedSearch, page, pageSize, ...extraParams })
+ .then(({ items, totalCount: total }) => {
+ setRows(items);
+ setTotalCount(total);
+ })
+ .catch((e: Error) => setErr(e.message || 'Failed to load'))
+ .finally(() => setLoading(false));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [fetchFn, debouncedSearch, page, pageSize, extraKey]);
+
+ useEffect(() => {
+ reload();
+ }, [reload]);
+
+ return useMemo(
+ () => ({
+ rows,
+ search,
+ setSearch,
+ page,
+ setPage,
+ pageSize,
+ setPageSize,
+ totalCount,
+ loading,
+ err,
+ reload,
+ }),
+ [rows, search, page, pageSize, totalCount, loading, err, reload],
+ );
+}
diff --git a/mobile/src/lib/api/admin.ts b/mobile/src/lib/api/admin.ts
new file mode 100644
index 0000000..634c795
--- /dev/null
+++ b/mobile/src/lib/api/admin.ts
@@ -0,0 +1,76 @@
+import apiClient from './client';
+
+export interface DashboardStats {
+ pendingCount: number;
+ approvedThisWeek: number;
+ avgTurnaroundHours: number;
+ totalProposals: number;
+}
+
+export interface UpdateProposalRequest {
+ refinedScope?: string;
+ notes?: string;
+ assignedAdminId?: string;
+}
+
+export interface AuditEntry {
+ id: string;
+ proposalId: string | null;
+ userId: string;
+ userName: string;
+ action: string;
+ details: Record;
+ timestamp: string;
+ ipAddress: string | null;
+}
+
+export const adminApi = {
+ getDashboard: async (): Promise => {
+ const res = await apiClient.get('/admin/dashboard');
+ return res.data;
+ },
+
+ updateProposal: async (
+ id: string,
+ data: UpdateProposalRequest,
+ ): Promise => {
+ await apiClient.put(`/proposals/${id}`, data);
+ },
+
+ approveProposal: async (id: string): Promise => {
+ await apiClient.post(`/proposals/${id}/approve`);
+ },
+
+ sendProposal: async (id: string): Promise => {
+ await apiClient.post(`/proposals/${id}/send`);
+ },
+
+ reviseProposal: async (id: string): Promise => {
+ await apiClient.post(`/proposals/${id}/revise`);
+ },
+
+ getAudit: async (id: string): Promise => {
+ const res = await apiClient.get(`/proposals/${id}/audit`);
+ return res.data;
+ },
+
+ generateSuggestions: async (id: string): Promise => {
+ await apiClient.post(`/proposals/${id}/generate-suggestions`);
+ },
+
+ getSimilar: async (id: string): Promise => {
+ const res = await apiClient.get(`/proposals/${id}/similar`);
+ return res.data;
+ },
+
+ getPdf: async (
+ id: string,
+ ): Promise<{ downloadUrl: string; expiresAt: string } | null> => {
+ try {
+ const res = await apiClient.get(`/proposals/${id}/pdf`);
+ return res.data;
+ } catch {
+ return null;
+ }
+ },
+};
diff --git a/mobile/src/lib/api/auth.ts b/mobile/src/lib/api/auth.ts
new file mode 100644
index 0000000..8ca649c
--- /dev/null
+++ b/mobile/src/lib/api/auth.ts
@@ -0,0 +1,78 @@
+import { authorize, refresh, revoke } from 'react-native-app-auth';
+import Config from '../../config';
+import apiClient from './client';
+import {
+ tokenStorage,
+ userStorage,
+ StoredTokens,
+ StoredUser,
+} from '../storage';
+
+const cognitoConfig = {
+ clientId: Config.COGNITO_CLIENT_ID,
+ redirectUrl: Config.COGNITO_REDIRECT_URI,
+ scopes: Config.COGNITO_SCOPES,
+ serviceConfiguration: {
+ authorizationEndpoint: `https://${Config.COGNITO_DOMAIN}/oauth2/authorize`,
+ tokenEndpoint: `https://${Config.COGNITO_DOMAIN}/oauth2/token`,
+ revocationEndpoint: `https://${Config.COGNITO_DOMAIN}/oauth2/revoke`,
+ },
+ usePKCE: true,
+ iosPrefersEphemeralSession: false,
+};
+
+export const authApi = {
+ login: async (): Promise => {
+ const result = await authorize(cognitoConfig);
+
+ const tokens: StoredTokens = {
+ accessToken: result.accessToken,
+ idToken: result.idToken,
+ refreshToken: result.refreshToken,
+ expiresAt: result.accessTokenExpirationDate,
+ };
+ await tokenStorage.save(tokens);
+
+ const profile = await authApi.getMe();
+ await userStorage.save(profile);
+ return profile;
+ },
+
+ refreshTokens: async (): Promise => {
+ const stored = await tokenStorage.get();
+ if (!stored?.refreshToken) throw new Error('No refresh token');
+
+ const result = await refresh(cognitoConfig, {
+ refreshToken: stored.refreshToken,
+ });
+
+ await tokenStorage.save({
+ accessToken: result.accessToken,
+ idToken: result.idToken || stored.idToken,
+ refreshToken: result.refreshToken || stored.refreshToken,
+ expiresAt: result.accessTokenExpirationDate,
+ });
+ },
+
+ logout: async (): Promise => {
+ const stored = await tokenStorage.get();
+ if (stored?.accessToken) {
+ try {
+ await revoke(cognitoConfig, {
+ tokenToRevoke: stored.accessToken,
+ includeBasicAuth: false,
+ sendClientId: true,
+ });
+ } catch {
+ // Best-effort revocation
+ }
+ }
+ await tokenStorage.clear();
+ await userStorage.clear();
+ },
+
+ getMe: async (): Promise => {
+ const res = await apiClient.get('/users/me');
+ return res.data;
+ },
+};
diff --git a/mobile/src/lib/api/client.ts b/mobile/src/lib/api/client.ts
new file mode 100644
index 0000000..22aa326
--- /dev/null
+++ b/mobile/src/lib/api/client.ts
@@ -0,0 +1,64 @@
+import axios from 'axios';
+import Config from '../../config';
+import { tokenStorage } from '../storage';
+
+const apiClient = axios.create({
+ baseURL: Config.API_URL,
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+});
+
+apiClient.interceptors.request.use(
+ async (config) => {
+ const tokens = await tokenStorage.get();
+ if (tokens?.accessToken) {
+ config.headers.Authorization = `Bearer ${tokens.accessToken}`;
+ }
+ return config;
+ },
+ (error) => Promise.reject(error),
+);
+
+apiClient.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (error.response) {
+ const { status, data } = error.response;
+
+ if (status === 401) {
+ tokenStorage.clear();
+ return Promise.reject(
+ new Error('Session expired. Please log in again.'),
+ );
+ }
+
+ if (status === 403) {
+ return Promise.reject(
+ new Error('You do not have permission to perform this action.'),
+ );
+ }
+
+ if (status === 404) {
+ return Promise.reject(
+ new Error('The requested resource was not found.'),
+ );
+ }
+
+ const message =
+ data?.detail || data?.title || data?.message || 'An error occurred';
+ return Promise.reject(new Error(message));
+ }
+
+ if (error.request) {
+ return Promise.reject(
+ new Error('No response from server. Please check your connection.'),
+ );
+ }
+
+ return Promise.reject(error);
+ },
+);
+
+export default apiClient;
diff --git a/mobile/src/lib/api/customers.ts b/mobile/src/lib/api/customers.ts
new file mode 100644
index 0000000..ca6f414
--- /dev/null
+++ b/mobile/src/lib/api/customers.ts
@@ -0,0 +1,24 @@
+import apiClient from './client';
+
+export interface Customer {
+ id: string;
+ name: string;
+ addresses: string[];
+ createdAt: string;
+}
+
+export const customersApi = {
+ search: async (query?: string): Promise => {
+ const params = query ? `?query=${encodeURIComponent(query)}` : '';
+ const res = await apiClient.get(`/customers${params}`);
+ return res.data;
+ },
+
+ create: async (data: {
+ name: string;
+ addresses?: string[];
+ }): Promise => {
+ const res = await apiClient.post('/customers', data);
+ return res.data;
+ },
+};
diff --git a/mobile/src/lib/api/lineItems.ts b/mobile/src/lib/api/lineItems.ts
new file mode 100644
index 0000000..a8f759c
--- /dev/null
+++ b/mobile/src/lib/api/lineItems.ts
@@ -0,0 +1,50 @@
+import apiClient from './client';
+import type { PricingMode, LineItemSource } from '@proposal-system/api-contracts';
+
+export interface LineItem {
+ id: string;
+ proposalId: string;
+ description: string;
+ quantity: number;
+ unit: string;
+ unitPrice: number | null;
+ totalPrice: number;
+ pricingMode: PricingMode;
+ sortOrder: number;
+ source: LineItemSource;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface UpdateLineItemEntry {
+ id: string | null;
+ description: string;
+ quantity: number;
+ unit: string;
+ unitPrice: number | null;
+ totalPrice: number;
+ pricingMode: PricingMode;
+ sortOrder: number;
+ source: LineItemSource;
+}
+
+export const lineItemsApi = {
+ getAll: async (proposalId: string): Promise => {
+ const res = await apiClient.get(`/proposals/${proposalId}/line-items`);
+ return res.data;
+ },
+
+ bulkUpdate: async (
+ proposalId: string,
+ lineItems: UpdateLineItemEntry[],
+ ): Promise => {
+ const res = await apiClient.put(`/proposals/${proposalId}/line-items`, {
+ lineItems,
+ });
+ return res.data;
+ },
+
+ delete: async (proposalId: string, itemId: string): Promise => {
+ await apiClient.delete(`/proposals/${proposalId}/line-items/${itemId}`);
+ },
+};
diff --git a/mobile/src/lib/api/proposals.ts b/mobile/src/lib/api/proposals.ts
new file mode 100644
index 0000000..39cc916
--- /dev/null
+++ b/mobile/src/lib/api/proposals.ts
@@ -0,0 +1,113 @@
+import apiClient from './client';
+import type { CreateProposalRequest } from '@proposal-system/api-contracts';
+
+export interface ProposalListItem {
+ id: string;
+ proposalNumber: string;
+ customerName: string;
+ workOrderNumber: string;
+ serviceCategory: string;
+ priority: string;
+ status: string;
+ totalBidAmount: number;
+ submittedAt: string;
+ submittedByName: string | null;
+ assignedAdminName: string | null;
+}
+
+export interface ProposalDetail {
+ id: string;
+ proposalNumber: string;
+ workOrderNumber: string;
+ customerName: string;
+ customerAddress: string;
+ scopeOfWork: string;
+ refinedScope: string | null;
+ serviceCategory: string;
+ priority: string;
+ status: string;
+ totalBidAmount: number;
+ vendorTotalCost: number | null;
+ notes: string;
+ submittedById: string;
+ submittedByName: string | null;
+ submittedAt: string;
+ assignedAdminId: string | null;
+ approvedById: string | null;
+ approvedAt: string | null;
+ sentAt: string | null;
+ currentRevision: number;
+ parentProposalId: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface PagedResponse {
+ items: T[];
+ totalCount: number;
+ page: number;
+ pageSize: number;
+}
+
+export interface ProposalFilters {
+ search?: string;
+ page?: number;
+ pageSize?: number;
+ status?: string;
+ serviceCategory?: string;
+ priority?: string;
+ fromDate?: string;
+ toDate?: string;
+}
+
+export interface ProposalStats {
+ totalCount: number;
+ inReviewCount: number;
+ approvedCount: number;
+ sentCount: number;
+}
+
+export { CreateProposalRequest };
+
+export const proposalsApi = {
+ create: async (data: CreateProposalRequest): Promise => {
+ const res = await apiClient.post('/proposals', data);
+ return res.data;
+ },
+
+ getAll: async (
+ filters: ProposalFilters = {},
+ ): Promise> => {
+ const params = new URLSearchParams();
+ if (filters.search) params.append('search', filters.search);
+ if (filters.page) params.append('page', String(filters.page));
+ if (filters.pageSize) params.append('pageSize', String(filters.pageSize));
+ if (filters.status) params.append('status', filters.status);
+ if (filters.serviceCategory)
+ params.append('serviceCategory', filters.serviceCategory);
+ if (filters.priority) params.append('priority', filters.priority);
+
+ const res = await apiClient.get(`/proposals?${params.toString()}`);
+ return res.data;
+ },
+
+ getById: async (id: string): Promise => {
+ const res = await apiClient.get(`/proposals/${id}`);
+ return res.data;
+ },
+
+ uploadAttachment: async (
+ proposalId: string,
+ fileName: string,
+ ): Promise<{ uploadUrl: string; s3Key: string }> => {
+ const res = await apiClient.post(
+ `/proposals/${proposalId}/attachments?fileName=${encodeURIComponent(fileName)}`,
+ );
+ return res.data;
+ },
+
+ getStats: async (): Promise => {
+ const res = await apiClient.get('/proposals/stats');
+ return res.data;
+ },
+};
diff --git a/mobile/src/lib/queryClient.ts b/mobile/src/lib/queryClient.ts
new file mode 100644
index 0000000..614a97e
--- /dev/null
+++ b/mobile/src/lib/queryClient.ts
@@ -0,0 +1,11 @@
+import { QueryClient } from '@tanstack/react-query';
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 5 * 60 * 1000,
+ gcTime: 10 * 60 * 1000,
+ retry: 1,
+ },
+ },
+});
diff --git a/mobile/src/lib/storage.ts b/mobile/src/lib/storage.ts
new file mode 100644
index 0000000..2acc5de
--- /dev/null
+++ b/mobile/src/lib/storage.ts
@@ -0,0 +1,67 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import * as Keychain from 'react-native-keychain';
+import { KEYCHAIN_SERVICE, STORAGE_KEY_USER } from '../constants';
+
+export interface StoredTokens {
+ accessToken: string;
+ idToken: string;
+ refreshToken: string;
+ expiresAt: string;
+}
+
+export interface StoredUser {
+ id: string;
+ email: string;
+ displayName: string;
+ role: 'Dispatcher' | 'Admin' | 'SysAdmin';
+}
+
+export const tokenStorage = {
+ save: async (tokens: StoredTokens): Promise => {
+ await Keychain.setGenericPassword('tokens', JSON.stringify(tokens), {
+ service: KEYCHAIN_SERVICE,
+ });
+ },
+
+ get: async (): Promise => {
+ const result = await Keychain.getGenericPassword({
+ service: KEYCHAIN_SERVICE,
+ });
+ if (!result) return null;
+ try {
+ return JSON.parse(result.password);
+ } catch {
+ return null;
+ }
+ },
+
+ clear: async (): Promise => {
+ await Keychain.resetGenericPassword({ service: KEYCHAIN_SERVICE });
+ },
+
+ isExpired: async (): Promise => {
+ const tokens = await tokenStorage.get();
+ if (!tokens) return true;
+ return new Date(tokens.expiresAt) <= new Date();
+ },
+};
+
+export const userStorage = {
+ save: async (user: StoredUser): Promise => {
+ await AsyncStorage.setItem(STORAGE_KEY_USER, JSON.stringify(user));
+ },
+
+ get: async (): Promise => {
+ const data = await AsyncStorage.getItem(STORAGE_KEY_USER);
+ if (!data) return null;
+ try {
+ return JSON.parse(data);
+ } catch {
+ return null;
+ }
+ },
+
+ clear: async (): Promise => {
+ await AsyncStorage.removeItem(STORAGE_KEY_USER);
+ },
+};
diff --git a/mobile/src/store/index.ts b/mobile/src/store/index.ts
new file mode 100644
index 0000000..00cafb7
--- /dev/null
+++ b/mobile/src/store/index.ts
@@ -0,0 +1,17 @@
+import { configureStore } from '@reduxjs/toolkit';
+import authReducer from './slices/authSlice';
+
+export const store = configureStore({
+ reducer: {
+ auth: authReducer,
+ },
+ middleware: (getDefaultMiddleware) =>
+ getDefaultMiddleware({
+ serializableCheck: {
+ ignoredActions: ['auth/setUser'],
+ },
+ }),
+});
+
+export type RootState = ReturnType;
+export type AppDispatch = typeof store.dispatch;
diff --git a/mobile/src/store/slices/authSlice.ts b/mobile/src/store/slices/authSlice.ts
new file mode 100644
index 0000000..ede3d8d
--- /dev/null
+++ b/mobile/src/store/slices/authSlice.ts
@@ -0,0 +1,57 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import type { StoredUser } from '../../lib/storage';
+
+interface AuthState {
+ user: StoredUser | null;
+ isAuthenticated: boolean;
+ loading: boolean;
+ error: string | null;
+}
+
+const initialState: AuthState = {
+ user: null,
+ isAuthenticated: false,
+ loading: true,
+ error: null,
+};
+
+const authSlice = createSlice({
+ name: 'auth',
+ initialState,
+ reducers: {
+ setUser: (state, action: PayloadAction) => {
+ state.user = action.payload;
+ state.isAuthenticated = true;
+ state.loading = false;
+ state.error = null;
+ },
+ logout: (state) => {
+ state.user = null;
+ state.isAuthenticated = false;
+ state.error = null;
+ },
+ setLoading: (state, action: PayloadAction) => {
+ state.loading = action.payload;
+ },
+ setError: (state, action: PayloadAction) => {
+ state.error = action.payload;
+ state.loading = false;
+ },
+ clearError: (state) => {
+ state.error = null;
+ },
+ },
+});
+
+export const { setUser, logout, setLoading, setError, clearError } =
+ authSlice.actions;
+
+export const selectUser = (state: { auth: AuthState }) => state.auth.user;
+export const selectIsAuthenticated = (state: { auth: AuthState }) =>
+ state.auth.isAuthenticated;
+export const selectAuthLoading = (state: { auth: AuthState }) =>
+ state.auth.loading;
+export const selectAuthError = (state: { auth: AuthState }) =>
+ state.auth.error;
+
+export default authSlice.reducer;
diff --git a/mobile/src/theme/index.ts b/mobile/src/theme/index.ts
new file mode 100644
index 0000000..7d1b6e5
--- /dev/null
+++ b/mobile/src/theme/index.ts
@@ -0,0 +1,36 @@
+import { MD3LightTheme } from 'react-native-paper';
+
+export const theme = {
+ ...MD3LightTheme,
+ colors: {
+ ...MD3LightTheme.colors,
+ primary: '#1565C0',
+ primaryContainer: '#E3F2FD',
+ secondary: '#2E7D32',
+ secondaryContainer: '#E8F5E9',
+ tertiary: '#E65100',
+ tertiaryContainer: '#FFF3E0',
+ error: '#D32F2F',
+ errorContainer: '#FFEBEE',
+ background: '#FAFAFA',
+ surface: '#FFFFFF',
+ surfaceVariant: '#F5F5F5',
+ outline: '#BDBDBD',
+ },
+};
+
+export const statusColors: Record = {
+ Draft: '#9E9E9E',
+ InReview: '#1565C0',
+ Approved: '#2E7D32',
+ Sent: '#7B1FA2',
+ Revised: '#E65100',
+};
+
+export const priorityColors: Record = {
+ Standard: '#757575',
+ Urgent: '#E65100',
+ Emergency: '#D32F2F',
+};
+
+export type AppTheme = typeof theme;