feat: Sprint 5 — PIN lock, biometric auth, sync screen, QR code, merge logic

- lib/pinService.ts: PIN storage/verification via expo-secure-store
- lib/syncService.ts: sync abstraction with full merge logic (mDNS/TCP stubbed)
- app/lock.tsx: full-screen PIN lock with biometric support
- components/ui/PinPad.tsx: reusable 6-digit PIN pad component
- components/sync/QRScanner.tsx: QR code scanner via expo-camera
- app/(tabs)/sync.tsx: Sync tab with QR display, partner input, history
- app/sync-progress.tsx: animated sync progress screen with summary
- app/(tabs)/settings.tsx: full settings with PIN setup, security, data sections
- app/(tabs)/_layout.tsx: added Sync tab (RefreshCw icon)
- app/_layout.tsx: AppState listener locks app after 60s background
- package.json: added expo-secure-store, react-native-qrcode-svg
This commit is contained in:
2026-02-18 19:57:35 +08:00
parent 123b21f4db
commit 6fe6ebef13
12 changed files with 18385 additions and 22 deletions

402
lib/syncService.ts Normal file
View File

@@ -0,0 +1,402 @@
// lib/syncService.ts
import { getDatabase } from '@/lib/database';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface SyncPartner {
id: string;
name: string;
address: string; // IP:port
}
export interface Contact {
id: string;
owner_id: string;
full_name: string;
address?: string;
household_count?: number;
gender?: string;
category: string;
status?: string;
tags?: string;
notes?: string;
territory_code?: string;
latitude?: number;
longitude?: number;
created_at: number;
updated_at: number;
deleted_at?: number | null;
}
export interface Visit {
id: string;
contact_id: string;
visited_by_name: string;
visited_by_id: string;
visit_date: number;
topic?: string;
response?: string;
remarks?: string;
next_visit_date?: number;
created_at: number;
updated_at: number;
}
export interface Territory {
id: string;
territory_code: string;
municipality?: string;
barangay?: string;
area?: string;
block?: string;
assigned_to?: string;
created_at: number;
updated_at: number;
}
export interface SyncPayload {
contacts: Contact[];
visits: Visit[];
territories: Territory[];
exportedAt: number;
exportedBy: string;
}
export interface SyncResult {
sent: number;
received: number;
conflicts: number;
}
// ── STUB: mDNS Discovery ──────────────────────────────────────────────────────
// TODO: Implement with react-native-zeroconf in bare workflow (expo-dev-client).
// This requires native modules that are not available in managed Expo Go.
// Steps to activate:
// 1. Run `npx expo eject` or use a bare workflow
// 2. Install: npm install react-native-zeroconf
// 3. Run `npx pod-install` (iOS) and rebuild
// 4. Replace this stub with actual Zeroconf implementation
export async function discoverPartners(): Promise<SyncPartner[]> {
// STUB — bare workflow required
throw new Error(
'mDNS discovery requires expo-dev-client and bare workflow. ' +
'Install react-native-zeroconf after ejecting from managed workflow.'
);
}
// ── STUB: TCP Sync Server ─────────────────────────────────────────────────────
// TODO: Implement with react-native-tcp-socket in bare workflow.
// Steps to activate:
// 1. Run `npx expo eject` or use a bare workflow
// 2. Install: npm install react-native-tcp-socket
// 3. Run `npx pod-install` (iOS) and rebuild
// 4. Replace this stub with actual TCP server implementation
export async function startSyncServer(port: number): Promise<void> {
// STUB — bare workflow required
throw new Error(
'TCP server requires expo-dev-client and bare workflow. ' +
'Install react-native-tcp-socket after ejecting from managed workflow.'
);
}
// ── Merge Logic (FULLY IMPLEMENTED) ──────────────────────────────────────────
/**
* Merges two arrays of records, resolving conflicts using updated_at timestamp.
* - Latest updated_at wins per record (by id)
* - Soft deletes (deleted_at != null) are respected
*/
export function mergeRecords<T extends {
id: string;
updated_at: number;
deleted_at?: number | null;
}>(local: T[], remote: T[]): T[] {
const merged = new Map<string, T>();
// Load all local records
for (const record of local) {
merged.set(record.id, record);
}
// Merge remote records: remote wins if updated_at is newer or equal
for (const remoteRecord of remote) {
const localRecord = merged.get(remoteRecord.id);
if (!localRecord) {
// New record from remote
merged.set(remoteRecord.id, remoteRecord);
} else {
// Conflict: pick the most recently updated
if (remoteRecord.updated_at >= localRecord.updated_at) {
merged.set(remoteRecord.id, remoteRecord);
}
// else: keep local (it's newer)
}
}
return Array.from(merged.values());
}
// ── Serialize for Sync (FULLY IMPLEMENTED) ────────────────────────────────────
/**
* Serializes local data into a SyncPayload for transmission.
*/
export function serializeForSync(
contacts: Contact[],
visits: Visit[],
territories: Territory[],
exportedBy: string
): SyncPayload {
return {
contacts,
visits,
territories,
exportedAt: Math.floor(Date.now() / 1000),
exportedBy,
};
}
// ── Apply Incoming Sync (FULLY IMPLEMENTED) ────────────────────────────────────
/**
* Applies a received SyncPayload to the local database.
* Merges records using updated_at conflict resolution.
* Returns a SyncResult with counts.
*/
export async function applyIncomingSync(
payload: SyncPayload,
localUserId: string
): Promise<SyncResult> {
const db = await getDatabase();
let received = 0;
let conflicts = 0;
// ── Territories ────────────────────────────────────────────────────────────
const localTerritories = await db.getAllAsync<Territory>(
'SELECT * FROM territories'
);
const mergedTerritories = mergeRecords(localTerritories, payload.territories);
for (const territory of mergedTerritories) {
const existing = localTerritories.find((t) => t.id === territory.id);
if (existing) {
if (territory.updated_at > existing.updated_at) {
conflicts++;
await db.runAsync(
`UPDATE territories SET
territory_code=?, municipality=?, barangay=?, area=?, block=?,
assigned_to=?, updated_at=?
WHERE id=?`,
[
territory.territory_code,
territory.municipality ?? null,
territory.barangay ?? null,
territory.area ?? null,
territory.block ?? null,
territory.assigned_to ?? null,
territory.updated_at,
territory.id,
]
);
received++;
}
} else {
await db.runAsync(
`INSERT OR IGNORE INTO territories
(id, territory_code, municipality, barangay, area, block, assigned_to, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?)`,
[
territory.id,
territory.territory_code,
territory.municipality ?? null,
territory.barangay ?? null,
territory.area ?? null,
territory.block ?? null,
territory.assigned_to ?? null,
territory.created_at,
territory.updated_at,
]
);
received++;
}
}
// ── Contacts ───────────────────────────────────────────────────────────────
const localContacts = await db.getAllAsync<Contact>(
'SELECT * FROM contacts'
);
const mergedContacts = mergeRecords(localContacts, payload.contacts);
for (const contact of mergedContacts) {
const existing = localContacts.find((c) => c.id === contact.id);
if (existing) {
if (contact.updated_at > existing.updated_at) {
conflicts++;
await db.runAsync(
`UPDATE contacts SET
owner_id=?, full_name=?, address=?, household_count=?, gender=?,
category=?, status=?, tags=?, notes=?, territory_code=?,
latitude=?, longitude=?, updated_at=?, deleted_at=?
WHERE id=?`,
[
contact.owner_id,
contact.full_name,
contact.address ?? null,
contact.household_count ?? 1,
contact.gender ?? null,
contact.category,
contact.status ?? 'Active',
contact.tags ?? '[]',
contact.notes ?? null,
contact.territory_code ?? null,
contact.latitude ?? null,
contact.longitude ?? null,
contact.updated_at,
contact.deleted_at ?? null,
contact.id,
]
);
received++;
}
} else {
await db.runAsync(
`INSERT OR IGNORE INTO contacts
(id, owner_id, full_name, address, household_count, gender, category,
status, tags, notes, territory_code, latitude, longitude,
created_at, updated_at, deleted_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[
contact.id,
contact.owner_id,
contact.full_name,
contact.address ?? null,
contact.household_count ?? 1,
contact.gender ?? null,
contact.category,
contact.status ?? 'Active',
contact.tags ?? '[]',
contact.notes ?? null,
contact.territory_code ?? null,
contact.latitude ?? null,
contact.longitude ?? null,
contact.created_at,
contact.updated_at,
contact.deleted_at ?? null,
]
);
received++;
}
}
// ── Visits ─────────────────────────────────────────────────────────────────
const localVisits = await db.getAllAsync<Visit>(
'SELECT * FROM visits'
);
const mergedVisits = mergeRecords(
localVisits.map((v) => ({ ...v, deleted_at: undefined })),
payload.visits.map((v) => ({ ...v, deleted_at: undefined }))
);
for (const visit of mergedVisits) {
const existing = localVisits.find((v) => v.id === visit.id);
if (existing) {
if (visit.updated_at > existing.updated_at) {
conflicts++;
await db.runAsync(
`UPDATE visits SET
contact_id=?, visited_by_name=?, visited_by_id=?, visit_date=?,
topic=?, response=?, remarks=?, next_visit_date=?, updated_at=?
WHERE id=?`,
[
visit.contact_id,
visit.visited_by_name,
visit.visited_by_id,
visit.visit_date,
visit.topic ?? null,
visit.response ?? null,
visit.remarks ?? null,
visit.next_visit_date ?? null,
visit.updated_at,
visit.id,
]
);
received++;
}
} else {
await db.runAsync(
`INSERT OR IGNORE INTO visits
(id, contact_id, visited_by_name, visited_by_id, visit_date,
topic, response, remarks, next_visit_date, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
[
visit.id,
visit.contact_id,
visit.visited_by_name,
visit.visited_by_id,
visit.visit_date,
visit.topic ?? null,
visit.response ?? null,
visit.remarks ?? null,
visit.next_visit_date ?? null,
visit.created_at,
visit.updated_at,
]
);
received++;
}
}
return {
sent: 0, // sent is tracked by the caller
received,
conflicts,
};
}
// ── Local data fetch helpers ───────────────────────────────────────────────────
export async function getLocalSyncData(): Promise<{
contacts: Contact[];
visits: Visit[];
territories: Territory[];
}> {
const db = await getDatabase();
const contacts = await db.getAllAsync<Contact>('SELECT * FROM contacts WHERE deleted_at IS NULL');
const visits = await db.getAllAsync<Visit>('SELECT * FROM visits');
const territories = await db.getAllAsync<Territory>('SELECT * FROM territories');
return { contacts, visits, territories };
}
// ── Sync Log helpers ──────────────────────────────────────────────────────────
export interface SyncLogEntry {
id: string;
partner_id: string;
partner_name: string | null;
synced_at: number;
sent_count: number;
received_count: number;
}
export async function getSyncHistory(): Promise<SyncLogEntry[]> {
const db = await getDatabase();
return db.getAllAsync<SyncLogEntry>(
'SELECT * FROM sync_log ORDER BY synced_at DESC LIMIT 50'
);
}
export async function recordSync(
partnerId: string,
partnerName: string | null,
sentCount: number,
receivedCount: number
): Promise<void> {
const db = await getDatabase();
const id = `sync_${Date.now()}`;
await db.runAsync(
'INSERT INTO sync_log (id, partner_id, partner_name, synced_at, sent_count, received_count) VALUES (?,?,?,?,?,?)',
[id, partnerId, partnerName, Math.floor(Date.now() / 1000), sentCount, receivedCount]
);
}