From c4fa5e261caa55d3e85195d71c383f21b0d254a2 Mon Sep 17 00:00:00 2001 From: ibdevlawal Date: Tue, 24 Mar 2026 13:10:00 +0100 Subject: [PATCH] feat: add mobile otp login and report submission --- apps/mobile/app/(app)/(tabs)/_layout.tsx | 16 ++ apps/mobile/app/(app)/(tabs)/index.tsx | 55 ++++++ apps/mobile/app/(app)/(tabs)/reports.tsx | 127 +++++++++++++ apps/mobile/app/(app)/(tabs)/settings.tsx | 57 ++++++ apps/mobile/app/(app)/_layout.tsx | 11 ++ apps/mobile/app/(auth)/_layout.tsx | 11 ++ apps/mobile/app/(auth)/login.tsx | 168 ++++++++++++++++++ apps/mobile/app/_layout.tsx | 13 +- apps/mobile/app/index.tsx | 58 +----- apps/mobile/app/lib/api.ts | 33 ++++ .../mobile/app/providers/session-provider.tsx | 83 +++++++++ apps/mobile/package-lock.json | 20 +++ apps/mobile/package.json | 9 +- 13 files changed, 600 insertions(+), 61 deletions(-) create mode 100644 apps/mobile/app/(app)/(tabs)/_layout.tsx create mode 100644 apps/mobile/app/(app)/(tabs)/index.tsx create mode 100644 apps/mobile/app/(app)/(tabs)/reports.tsx create mode 100644 apps/mobile/app/(app)/(tabs)/settings.tsx create mode 100644 apps/mobile/app/(app)/_layout.tsx create mode 100644 apps/mobile/app/(auth)/_layout.tsx create mode 100644 apps/mobile/app/(auth)/login.tsx create mode 100644 apps/mobile/app/lib/api.ts create mode 100644 apps/mobile/app/providers/session-provider.tsx diff --git a/apps/mobile/app/(app)/(tabs)/_layout.tsx b/apps/mobile/app/(app)/(tabs)/_layout.tsx new file mode 100644 index 0000000..c83e927 --- /dev/null +++ b/apps/mobile/app/(app)/(tabs)/_layout.tsx @@ -0,0 +1,16 @@ +import { Tabs } from 'expo-router'; + +export default function TabsLayout() { + return ( + + + + + + ); +} diff --git a/apps/mobile/app/(app)/(tabs)/index.tsx b/apps/mobile/app/(app)/(tabs)/index.tsx new file mode 100644 index 0000000..5bee0d9 --- /dev/null +++ b/apps/mobile/app/(app)/(tabs)/index.tsx @@ -0,0 +1,55 @@ +import { Link } from 'expo-router'; +import { StyleSheet, Text, View } from 'react-native'; + +export default function HomeScreen() { + return ( + + Mobile Foundation + Signed-in app shell. + + Tabs, routing, environment-aware API helpers, and session context are in place for + report and auth flows. + + + Open reports tab + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + padding: 24, + backgroundColor: '#fffaf2', + }, + eyebrow: { + textTransform: 'uppercase', + letterSpacing: 2, + color: '#2f5d50', + marginBottom: 12, + fontWeight: '700', + }, + title: { + fontSize: 34, + fontWeight: '700', + color: '#112219', + }, + copy: { + marginTop: 16, + color: '#405149', + lineHeight: 22, + }, + secondaryButton: { + marginTop: 24, + borderColor: '#d9d0bf', + borderWidth: 1, + color: '#112219', + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 999, + overflow: 'hidden', + fontWeight: '700', + }, +}); diff --git a/apps/mobile/app/(app)/(tabs)/reports.tsx b/apps/mobile/app/(app)/(tabs)/reports.tsx new file mode 100644 index 0000000..69e299d --- /dev/null +++ b/apps/mobile/app/(app)/(tabs)/reports.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { authorizedApiFetch } from '../../lib/api'; +import { useSession } from '../../providers/session-provider'; + +export default function ReportsPlaceholderScreen() { + const { accessToken } = useSession(); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const submitReport = async () => { + if (!accessToken) { + setError('Sign in before submitting a report.'); + return; + } + + try { + setError(null); + const payload = await authorizedApiFetch<{ report_id: string }>('/api/reports', accessToken, { + method: 'POST', + body: JSON.stringify({ + title, + description, + category: 'INFRASTRUCTURE', + location: { + type: 'Point', + coordinates: [3.3515, 6.6018], + }, + media_urls: [], + }), + }); + setMessage(`Report submitted: ${payload.report_id}`); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : 'Unable to submit report.'); + } + }; + + return ( + + Quick report submission + + This flow uses the current authenticated mobile session and submits directly to the + report API while richer media and location capture lands later. + + + + + + + Submit report + + + {message ? {message} : null} + {error ? {error} : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + padding: 24, + backgroundColor: '#fff', + }, + title: { + fontSize: 28, + fontWeight: '700', + color: '#112219', + }, + copy: { + marginTop: 12, + color: '#51615a', + lineHeight: 22, + }, + input: { + marginTop: 16, + borderWidth: 1, + borderColor: '#d2c7b5', + borderRadius: 16, + paddingHorizontal: 16, + paddingVertical: 14, + backgroundColor: '#fffaf2', + color: '#112219', + }, + textarea: { + minHeight: 120, + textAlignVertical: 'top', + }, + button: { + marginTop: 18, + backgroundColor: '#1f4d3f', + borderRadius: 999, + paddingHorizontal: 18, + paddingVertical: 14, + alignItems: 'center', + }, + buttonText: { + color: '#f8fff8', + fontWeight: '700', + }, + success: { + marginTop: 16, + color: '#1f4d3f', + fontWeight: '600', + }, + error: { + marginTop: 16, + color: '#8a1c1c', + fontWeight: '600', + }, +}); diff --git a/apps/mobile/app/(app)/(tabs)/settings.tsx b/apps/mobile/app/(app)/(tabs)/settings.tsx new file mode 100644 index 0000000..fe891e0 --- /dev/null +++ b/apps/mobile/app/(app)/(tabs)/settings.tsx @@ -0,0 +1,57 @@ +import { Link } from 'expo-router'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useSession } from '../../providers/session-provider'; + +export default function SettingsPlaceholderScreen() { + const { clearSession } = useSession(); + + return ( + + Settings placeholder + Session controls and profile settings will land here. + void clearSession()} style={styles.button}> + Clear local session + + + Return to auth shell + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + padding: 24, + backgroundColor: '#fff', + }, + title: { + fontSize: 28, + fontWeight: '700', + color: '#112219', + }, + copy: { + marginTop: 12, + color: '#51615a', + lineHeight: 22, + }, + button: { + marginTop: 20, + borderWidth: 1, + borderColor: '#d2c7b5', + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 999, + alignItems: 'center', + }, + buttonText: { + color: '#112219', + fontWeight: '700', + }, + link: { + marginTop: 20, + color: '#1f4d3f', + fontWeight: '700', + }, +}); diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx new file mode 100644 index 0000000..9ebb11e --- /dev/null +++ b/apps/mobile/app/(app)/_layout.tsx @@ -0,0 +1,11 @@ +import { Stack } from 'expo-router'; + +export default function AppLayout() { + return ( + + ); +} diff --git a/apps/mobile/app/(auth)/_layout.tsx b/apps/mobile/app/(auth)/_layout.tsx new file mode 100644 index 0000000..149c586 --- /dev/null +++ b/apps/mobile/app/(auth)/_layout.tsx @@ -0,0 +1,11 @@ +import { Stack } from 'expo-router'; + +export default function AuthLayout() { + return ( + + ); +} diff --git a/apps/mobile/app/(auth)/login.tsx b/apps/mobile/app/(auth)/login.tsx new file mode 100644 index 0000000..6280c2c --- /dev/null +++ b/apps/mobile/app/(auth)/login.tsx @@ -0,0 +1,168 @@ +import { Redirect } from 'expo-router'; +import { useState } from 'react'; +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { apiFetch } from '../lib/api'; +import { useSession } from '../providers/session-provider'; + +export default function LoginScreen() { + const { accessToken, isHydrated, setSession } = useSession(); + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + if (isHydrated && accessToken) { + return ; + } + + const requestOtp = async () => { + try { + setError(null); + const payload = await apiFetch<{ expiresInSeconds: number }>('/api/auth/request-otp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + setMessage(`OTP requested. Expires in about ${Math.ceil(payload.expiresInSeconds / 60)} minutes.`); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : 'Unable to request OTP.'); + } + }; + + const verifyOtp = async () => { + try { + setError(null); + const payload = await apiFetch<{ accessToken: string; refreshToken: string }>( + '/api/auth/verify-otp', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + code, + deviceId: 'mobile-handset', + clientType: 'mobile', + role: 'CITIZEN', + }), + }, + ); + await setSession({ + accessToken: payload.accessToken, + refreshToken: payload.refreshToken, + }); + setMessage('Signed in successfully.'); + } catch (verifyError) { + setError(verifyError instanceof Error ? verifyError.message : 'Unable to verify OTP.'); + } + }; + + return ( + + Sidewalk Mobile + OTP login ready. + + Request a login code, verify it, and persist the mobile session for app restarts. + + + + + + + Request OTP + + + Verify OTP + + + {message ? {message} : null} + {error ? {error} : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + padding: 24, + backgroundColor: '#f4efe6', + }, + eyebrow: { + textTransform: 'uppercase', + letterSpacing: 2, + color: '#2f5d50', + marginBottom: 12, + fontWeight: '700', + }, + title: { + fontSize: 36, + fontWeight: '700', + color: '#112219', + }, + copy: { + marginTop: 16, + color: '#405149', + lineHeight: 22, + }, + input: { + marginTop: 18, + borderWidth: 1, + borderColor: '#d2c7b5', + borderRadius: 16, + paddingHorizontal: 16, + paddingVertical: 14, + backgroundColor: '#fff', + color: '#112219', + }, + primaryButton: { + marginTop: 18, + backgroundColor: '#1f4d3f', + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 999, + alignItems: 'center', + }, + primaryButtonText: { + color: '#f8fff8', + fontWeight: '700', + }, + secondaryButton: { + marginTop: 12, + borderWidth: 1, + borderColor: '#d2c7b5', + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 999, + alignItems: 'center', + }, + secondaryButtonText: { + color: '#112219', + fontWeight: '700', + }, + success: { + marginTop: 18, + color: '#1f4d3f', + fontWeight: '600', + }, + error: { + marginTop: 18, + color: '#8a1c1c', + fontWeight: '600', + }, +}); diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index d2a8b0b..6bd59dd 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -1,5 +1,14 @@ -import { Stack } from "expo-router"; +import { Stack } from 'expo-router'; +import { SessionProvider } from './providers/session-provider'; export default function RootLayout() { - return ; + return ( + + + + ); } diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index 703397b..11805fd 100644 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -1,57 +1,5 @@ -import { StatusBar } from 'expo-status-bar'; -import { useEffect, useState } from 'react'; -import { StyleSheet, Text, View, ActivityIndicator } from 'react-native'; +import { Redirect } from 'expo-router'; -export default function App() { - const [status, setStatus] = useState('Checking...'); - const [loading, setLoading] = useState(true); - - const API_URL = 'http://localhost:5001/api/health'; - - useEffect(() => { - fetch(API_URL) - .then((res) => res.json()) - .then((data) => { - setStatus(`API: ${data.status}\nStellar: ${data.stellar_connected}`); - setLoading(false); - }) - .catch((err) => { - setStatus('Error connecting to API'); - console.error(err); - setLoading(false); - }); - }, []); - - return ( - - Sidewalk 🌍 - - {loading ? ( - - ) : ( - {status} - )} - - - - ); +export default function Index() { + return ; } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - }, - title: { - fontSize: 24, - fontWeight: 'bold', - marginBottom: 20, - }, - status: { - fontSize: 16, - textAlign: 'center', - color: '#333', - }, -}); diff --git a/apps/mobile/app/lib/api.ts b/apps/mobile/app/lib/api.ts new file mode 100644 index 0000000..a353035 --- /dev/null +++ b/apps/mobile/app/lib/api.ts @@ -0,0 +1,33 @@ +import Constants from 'expo-constants'; + +const configuredUrl = + Constants.expoConfig?.extra?.apiBaseUrl ?? + process.env.EXPO_PUBLIC_API_BASE_URL ?? + 'http://localhost:5001'; + +export const apiBaseUrl = configuredUrl.replace(/\/+$/, ''); + +export const apiFetch = async (path: string, init?: RequestInit): Promise => { + const response = await fetch(`${apiBaseUrl}${path}`, init); + const payload = (await response.json()) as T & { error?: { message?: string } }; + + if (!response.ok) { + throw new Error(payload.error?.message ?? 'Request failed'); + } + + return payload; +}; + +export const authorizedApiFetch = async ( + path: string, + accessToken: string, + init?: RequestInit, +): Promise => + apiFetch(path, { + ...init, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + ...(init?.headers ?? {}), + }, + }); diff --git a/apps/mobile/app/providers/session-provider.tsx b/apps/mobile/app/providers/session-provider.tsx new file mode 100644 index 0000000..e0baef9 --- /dev/null +++ b/apps/mobile/app/providers/session-provider.tsx @@ -0,0 +1,83 @@ +'use client'; + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from 'react'; + +type SessionState = { + accessToken: string | null; + refreshToken: string | null; + isHydrated: boolean; + setSession: (params: { accessToken: string; refreshToken?: string | null }) => Promise; + clearSession: () => Promise; +}; + +const SessionContext = createContext(null); + +const ACCESS_TOKEN_KEY = 'sidewalk:mobile:access-token'; +const REFRESH_TOKEN_KEY = 'sidewalk:mobile:refresh-token'; + +export function SessionProvider({ children }: Readonly<{ children: ReactNode }>) { + const [accessToken, setAccessToken] = useState(null); + const [refreshToken, setRefreshToken] = useState(null); + const [isHydrated, setIsHydrated] = useState(false); + + useEffect(() => { + const hydrate = async () => { + const [storedAccessToken, storedRefreshToken] = await Promise.all([ + AsyncStorage.getItem(ACCESS_TOKEN_KEY), + AsyncStorage.getItem(REFRESH_TOKEN_KEY), + ]); + + setAccessToken(storedAccessToken); + setRefreshToken(storedRefreshToken); + setIsHydrated(true); + }; + + void hydrate(); + }, []); + + const value = useMemo( + () => ({ + accessToken, + refreshToken, + isHydrated, + setSession: async ({ + accessToken: nextAccessToken, + refreshToken: nextRefreshToken, + }: { + accessToken: string; + refreshToken?: string | null; + }) => { + setAccessToken(nextAccessToken); + if (nextRefreshToken !== undefined) { + setRefreshToken(nextRefreshToken); + } + + await AsyncStorage.setItem(ACCESS_TOKEN_KEY, nextAccessToken); + if (nextRefreshToken) { + await AsyncStorage.setItem(REFRESH_TOKEN_KEY, nextRefreshToken); + } + }, + clearSession: async () => { + setAccessToken(null); + setRefreshToken(null); + await Promise.all([ + AsyncStorage.removeItem(ACCESS_TOKEN_KEY), + AsyncStorage.removeItem(REFRESH_TOKEN_KEY), + ]); + }, + }), + [accessToken, isHydrated, refreshToken], + ); + + return {children}; +} + +export const useSession = () => { + const context = useContext(SessionContext); + if (!context) { + throw new Error('useSession must be used within SessionProvider'); + } + + return context; +}; diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index f57700b..4581cdc 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "^3.0.2", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", @@ -2787,6 +2788,19 @@ } } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-3.0.2.tgz", + "integrity": "sha512-XP0zDIl+1XoeuQ7f878qXKdl77zLwzLALPpxvNRc7ZtDh9ew36WSvOdQOhFkexMySapFAWxEbZxS8K8J2DU4eg==", + "license": "MIT", + "dependencies": { + "idb": "8.0.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", @@ -7385,6 +7399,12 @@ "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause" }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b271403..858b273 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "^3.0.2", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", @@ -32,17 +33,17 @@ "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", - "react-native-worklets": "0.5.1", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", - "react-native-web": "~0.21.0" + "react-native-web": "~0.21.0", + "react-native-worklets": "0.5.1" }, "devDependencies": { "@types/react": "~19.1.0", - "typescript": "~5.9.2", "eslint": "^9.25.0", - "eslint-config-expo": "~10.0.0" + "eslint-config-expo": "~10.0.0", + "typescript": "~5.9.2" }, "private": true }