mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-05 10:42:00 +00:00
* Add iOS native project for React Native mobile app Xcode project with bundle ID com.seahavenind.proposals, CocoaPods configuration, and app scaffolding. * Add Fastlane configuration for iOS builds and TestFlight distribution Configures match with S3 storage (seahaven-ios-certificates bucket) for code signing and a beta lane for automated TestFlight uploads. * Add mobile CI job and iOS CD workflow (disabled) CI: adds mobile typecheck job on PRs. CD: deploy-mobile.yaml builds and uploads to TestFlight via Fastlane on a macOS runner with OIDC auth for match S3 access. Currently workflow_dispatch only — activate for V1 release. * Refactor workflows to thin wrappers calling org reusable workflows CI jobs now call ci-dotnet, ci-typescript-cdk, and ci-python-sam from the org repo. Deploy calls cd-cdk with post-deploy script for web build/S3/CloudFront. Mobile deploy calls cd-mobile-ios. Adds deploy concurrency groups to both deploy workflows. * Add mobile Dependabot entries and remove assignees Add npm and bundler ecosystems for mobile/. Remove assignees from all entries — convention no longer in use. * Add comprehensive README for the proposal-system monorepo * Fix mobile TypeScript errors and add package-lock.json Fix tsconfig.json (remove rootDir/outDir, add noEmit), fix useRef type error, fix navigation type cast, add @types/react-native-vector-icons, and generate package-lock.json for CI. * Add .npmrc for mobile to resolve peer dependency conflicts react-native-screens@4.x requires react-native >= 0.82 but the project uses 0.79. legacy-peer-deps allows installation until the next React Native upgrade.
100 lines
2.8 KiB
TypeScript
100 lines
2.8 KiB
TypeScript
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<T extends Record<string, unknown>>(
|
|
initialData: T,
|
|
) {
|
|
const [draft, setDraft] = useState<T>(initialData);
|
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
const saveTimeout = useRef<ReturnType<typeof setTimeout>>(null);
|
|
|
|
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<T>) => {
|
|
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<void> {
|
|
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<unknown>,
|
|
): Promise<number> {
|
|
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<number> {
|
|
const stored = await AsyncStorage.getItem(OFFLINE_QUEUE_KEY);
|
|
if (!stored) return 0;
|
|
return JSON.parse(stored).length;
|
|
}
|