diff --git a/formulus/App.tsx b/formulus/App.tsx index 28e1a183f..aad9bf509 100644 --- a/formulus/App.tsx +++ b/formulus/App.tsx @@ -1,24 +1,17 @@ -import React, { useEffect, useState } from 'react'; -import { NavigationContainer, DefaultTheme } from '@react-navigation/native'; -import { createStackNavigator } from '@react-navigation/stack'; -import WelcomeScreen from './src/screens/WelcomeScreen'; -import SettingsScreen from './src/screens/SettingsScreen'; -import SyncScreen from './src/screens/SyncScreen'; -import HomeScreen from './src/screens/HomeScreen'; -import FormManagementScreen from './src/screens/FormManagementScreen'; +import React, {useEffect, useState} from 'react'; +import {NavigationContainer, DefaultTheme} from '@react-navigation/native'; +import {StatusBar} from 'react-native'; +import 'react-native-url-polyfill/auto'; +import {FormService} from './src/services/FormService'; +import {SyncProvider} from './src/contexts/SyncContext'; +import {appEvents} from './src/webview/FormulusMessageHandlers.ts'; +import FormplayerModal, { + FormplayerModalHandle, +} from './src/components/FormplayerModal'; import QRScannerModal from './src/components/QRScannerModal'; import SignatureCaptureModal from './src/components/SignatureCaptureModal'; -import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; -import { StatusBar, TouchableOpacity, View } from 'react-native'; -import 'react-native-url-polyfill/auto' // To support axios autogenerated API client -import { FormService } from './src/services/FormService'; -import { SyncProvider } from './src/contexts/SyncContext'; -import { appEvents } from './src/webview/FormulusMessageHandlers.ts'; -import FormplayerModal, { FormplayerModalHandle } from './src/components/FormplayerModal'; +import MainAppNavigator from './src/navigation/MainAppNavigator'; -const Stack = createStackNavigator(); - -// Always use a light theme for the app, regardless of system dark mode const LightNavigationTheme = { ...DefaultTheme, dark: false, @@ -33,39 +26,6 @@ const LightNavigationTheme = { }, }; -const NavigationButtons = ({ navigation }: { navigation: any }) => ( - - navigation.navigate('FormManagement')} - style={{ marginRight: 16 }} - accessibilityLabel="Form Management" - > - - - navigation.navigate('Home')} - style={{ marginRight: 8 }} - accessibilityLabel="Go to Home" - > - - - navigation.navigate('Settings')} - style={{ marginRight: 8 }} - accessibilityLabel="Go to Settings" - > - - - navigation.navigate('Sync')} - style={{ marginRight: 8 }} - accessibilityLabel="Sync Data" - > - - - -); - function App(): React.JSX.Element { const [qrScannerVisible, setQrScannerVisible] = useState(false); const [qrScannerData, setQrScannerData] = useState<{ @@ -79,7 +39,6 @@ function App(): React.JSX.Element { onResult: (result: any) => void; } | null>(null); - // Global Formplayer modal state so it works from any screen const [formplayerVisible, setFormplayerVisible] = useState(false); const formplayerModalRef = React.useRef(null); const formplayerVisibleRef = React.useRef(false); @@ -89,21 +48,20 @@ function App(): React.JSX.Element { }, [formplayerVisible]); useEffect(() => { - // pre-load form service - FormService.getInstance().then(() => { - console.log('FormService pre-loaded'); - }); - - // Listen for QR scanner requests - const handleOpenQRScanner = (data: { fieldId: string; onResult: (result: any) => void }) => { - console.log('Opening QR scanner for field:', data.fieldId); + FormService.getInstance(); + + const handleOpenQRScanner = (data: { + fieldId: string; + onResult: (result: any) => void; + }) => { setQrScannerData(data); setQrScannerVisible(true); }; - // Listen for signature capture requests - const handleOpenSignatureCapture = (data: { fieldId: string; onResult: (result: any) => void }) => { - console.log('Opening signature capture for field:', data.fieldId); + const handleOpenSignatureCapture = (data: { + fieldId: string; + onResult: (result: any) => void; + }) => { setSignatureCaptureData(data); setSignatureCaptureVisible(true); }; @@ -111,18 +69,12 @@ function App(): React.JSX.Element { appEvents.addListener('openQRScanner', handleOpenQRScanner); appEvents.addListener('openSignatureCapture', handleOpenSignatureCapture); - // Global Formplayer open/close handlers so any screen can trigger it const handleOpenFormplayer = async (config: any) => { - console.log('App: openFormplayerRequested event received', config); - if (formplayerVisibleRef.current) { - console.log('App: FormplayerModal already visible, ignoring open request'); return; } - const { formType, observationId, params, savedData, operationId } = config; - - console.log('App: Opening FormplayerModal'); + const {formType, observationId, params, savedData, operationId} = config; formplayerVisibleRef.current = true; setFormplayerVisible(true); @@ -130,13 +82,11 @@ function App(): React.JSX.Element { const forms = formService.getFormSpecs(); if (forms.length === 0) { - console.warn('App: No forms available when trying to open Formplayer'); return; } - const formSpec = forms.find((form) => form.id === formType); + const formSpec = forms.find(form => form.id === formType); if (!formSpec) { - console.warn(`App: Form ${formType} not found when trying to open Formplayer`); return; } @@ -149,8 +99,7 @@ function App(): React.JSX.Element { ); }; - const handleCloseFormplayer = (data: any) => { - console.log('App: closeFormplayer event received', data); + const handleCloseFormplayer = () => { formplayerVisibleRef.current = false; setFormplayerVisible(false); }; @@ -160,79 +109,53 @@ function App(): React.JSX.Element { return () => { appEvents.removeListener('openQRScanner', handleOpenQRScanner); - appEvents.removeListener('openSignatureCapture', handleOpenSignatureCapture); + appEvents.removeListener( + 'openSignatureCapture', + handleOpenSignatureCapture, + ); appEvents.removeListener('openFormplayerRequested', handleOpenFormplayer); appEvents.removeListener('closeFormplayer', handleCloseFormplayer); }; }, []); - const handleQRScannerClose = () => { - setQrScannerVisible(false); - setQrScannerData(null); - }; - - const handleSignatureCaptureClose = () => { - setSignatureCaptureVisible(false); - setSignatureCaptureData(null); - }; - - const handleSignatureCaptureResult = (result: any) => { - if (signatureCaptureData?.onResult) { - signatureCaptureData.onResult(result); - } - }; return ( - <> - ({ - headerRight: () => , - headerStyle: { backgroundColor: '#ffffff' }, - headerTintColor: '#000000', - headerTitleStyle: { color: '#000000' }, - })} - > - - - - - - - - {/* Global Formplayer Modal so it can be opened from any screen */} - { - console.log('App: FormplayerModal onClose called'); - formplayerVisibleRef.current = false; - setFormplayerVisible(false); - }} - /> - + + { + formplayerVisibleRef.current = false; + setFormplayerVisible(false); + }} + /> - {/* QR Scanner Modal */} { + setQrScannerVisible(false); + setQrScannerData(null); + }} fieldId={qrScannerData?.fieldId} onResult={qrScannerData?.onResult} /> - - {/* Signature Capture Modal */} + { + setSignatureCaptureVisible(false); + setSignatureCaptureData(null); + }} fieldId={signatureCaptureData?.fieldId || ''} - onSignatureCapture={handleSignatureCaptureResult} + onSignatureCapture={(result: any) => { + signatureCaptureData?.onResult?.(result); + }} /> ); } -export default App; \ No newline at end of file +export default App; diff --git a/formulus/assets/images/logo.png b/formulus/assets/images/logo.png new file mode 100644 index 000000000..a5994bc28 Binary files /dev/null and b/formulus/assets/images/logo.png differ diff --git a/formulus/assets/webview/placeholder_app.html b/formulus/assets/webview/placeholder_app.html index 667bbb2ef..ec8065c24 100644 --- a/formulus/assets/webview/placeholder_app.html +++ b/formulus/assets/webview/placeholder_app.html @@ -10,27 +10,35 @@

Your Custom App

-

This is an placeholder. Your app will appear here after sync.

+

This is a placeholder. Your app will appear here after sync.

\ No newline at end of file diff --git a/formulus/package-lock.json b/formulus/package-lock.json index 305d25a6f..b241e57d7 100644 --- a/formulus/package-lock.json +++ b/formulus/package-lock.json @@ -12,6 +12,7 @@ "@nozbe/watermelondb": "^0.28.0", "@react-native-async-storage/async-storage": "^1.24.0", "@react-native-documents/picker": "^10.1.5", + "@react-navigation/bottom-tabs": "^7.8.11", "@react-navigation/native": "^7.1.8", "@react-navigation/stack": "^7.3.1", "@testing-library/react-native": "^13.2.0", @@ -3968,14 +3969,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@react-navigation/bottom-tabs": { + "version": "7.8.11", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.8.11.tgz", + "integrity": "sha512-lUc8cYpez3uVi7IlqKgIBpLEEkYiL4LkZnpstDsb0OSRxW8VjVYVrH29AqKU7n1svk++vffJvv3EeW+IgxkJtg==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.1", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^7.1.24", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, "node_modules/@react-navigation/core": { - "version": "7.12.4", - "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.12.4.tgz", - "integrity": "sha512-xLFho76FA7v500XID5z/8YfGTvjQPw7/fXsq4BIrVSqetNe/o/v+KAocEw4ots6kyv3XvSTyiWKh2g3pN6xZ9Q==", + "version": "7.13.5", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.13.5.tgz", + "integrity": "sha512-4aTSHPWa3oQPLoanFYnzR2tyQmVRD6qsWsPigW8qAdSDA0ngl/h9dl2h9XvDPcOb7PKeVVVhbukRyytkXKf50w==", "license": "MIT", "dependencies": { - "@react-navigation/routers": "^7.5.1", + "@react-navigation/routers": "^7.5.2", "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", @@ -3987,9 +4007,9 @@ } }, "node_modules/@react-navigation/elements": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.6.4.tgz", - "integrity": "sha512-O3X9vWXOEhAO56zkQS7KaDzL8BvjlwZ0LGSteKpt1/k6w6HONG+2Wkblrb057iKmehTkEkQMzMLkXiuLmN5x9Q==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.1.tgz", + "integrity": "sha512-Jn2F+tXiQOY8L5mLMety6tfQUwBA8daz3whQmI8utvFvtSdfutOqH9P5ZC/QjlZEY5zcA4ZeuDzM0LKkrtFgqw==", "license": "MIT", "dependencies": { "color": "^4.2.3", @@ -3998,7 +4018,7 @@ }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.1.17", + "@react-navigation/native": "^7.1.24", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" @@ -4010,12 +4030,12 @@ } }, "node_modules/@react-navigation/native": { - "version": "7.1.17", - "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.17.tgz", - "integrity": "sha512-uEcYWi1NV+2Qe1oELfp9b5hTYekqWATv2cuwcOAg5EvsIsUPtzFrKIasgUXLBRGb9P7yR5ifoJ+ug4u6jdqSTQ==", + "version": "7.1.24", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.24.tgz", + "integrity": "sha512-L9glh8MywAtD1h6O65Y1alGDi2FsLEBYnXkb9sx3UPSbG7pkWEnLbkEy7rWgi4Vr+DZUS18VmFsCKPmczOWcow==", "license": "MIT", "dependencies": { - "@react-navigation/core": "^7.12.4", + "@react-navigation/core": "^7.13.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", @@ -4027,9 +4047,9 @@ } }, "node_modules/@react-navigation/routers": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.1.tgz", - "integrity": "sha512-pxipMW/iEBSUrjxz2cDD7fNwkqR4xoi0E/PcfTQGCcdJwLoaxzab5kSadBLj1MTJyT0YRrOXL9umHpXtp+Dv4w==", + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.2.tgz", + "integrity": "sha512-kymreY5aeTz843E+iPAukrsOtc7nabAH6novtAPREmmGu77dQpfxPB2ZWpKb5nRErIRowp1kYRoN2Ckl+S6JYw==", "license": "MIT", "dependencies": { "nanoid": "^3.3.11" @@ -13558,6 +13578,15 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sf-symbols-typescript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz", + "integrity": "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/formulus/package.json b/formulus/package.json index 4105d044e..f6477efd8 100644 --- a/formulus/package.json +++ b/formulus/package.json @@ -18,6 +18,7 @@ "@nozbe/watermelondb": "^0.28.0", "@react-native-async-storage/async-storage": "^1.24.0", "@react-native-documents/picker": "^10.1.5", + "@react-navigation/bottom-tabs": "^7.8.11", "@react-navigation/native": "^7.1.8", "@react-navigation/stack": "^7.3.1", "@testing-library/react-native": "^13.2.0", diff --git a/formulus/src/api/synkronus/Auth.ts b/formulus/src/api/synkronus/Auth.ts index 3cf1e7b59..72ac4a337 100644 --- a/formulus/src/api/synkronus/Auth.ts +++ b/formulus/src/api/synkronus/Auth.ts @@ -1,8 +1,28 @@ import { synkronusApi } from './index' import AsyncStorage from '@react-native-async-storage/async-storage' -export const login = async (username: string, password: string) => { - console.log('Logging in with', username, password) +export type UserRole = 'read-only' | 'read-write' | 'admin'; + +export interface UserInfo { + username: string; + role: UserRole; +} + +// Decode JWT payload without verification (claims are in the middle part) +function decodeJwtPayload(token: string): any { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + const payload = parts[1]; + const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); + return JSON.parse(decoded); + } catch { + return null; + } +} + +export const login = async (username: string, password: string): Promise => { + console.log('Logging in with', username) const api = await synkronusApi.getApi() const res = await api.login({ @@ -15,9 +35,35 @@ export const login = async (username: string, password: string) => { await AsyncStorage.setItem('@refreshToken', refreshToken) await AsyncStorage.setItem('@tokenExpiresAt', expiresAt.toString()) - return true + // Decode JWT to get user info + const claims = decodeJwtPayload(token); + const userInfo: UserInfo = { + username: claims?.username || username, + role: claims?.role || 'read-only', + }; + + // Store user info + await AsyncStorage.setItem('@user', JSON.stringify(userInfo)); + + return userInfo; } +export const getUserInfo = async (): Promise => { + try { + const userJson = await AsyncStorage.getItem('@user'); + if (userJson) { + return JSON.parse(userJson); + } + return null; + } catch { + return null; + } +}; + +export const logout = async (): Promise => { + await AsyncStorage.multiRemove(['@token', '@refreshToken', '@tokenExpiresAt', '@user']); +}; + // Function to retrieve the auth token from AsyncStorage export const getApiAuthToken = async (): Promise => { try { diff --git a/formulus/src/components/CustomAppWebView.tsx b/formulus/src/components/CustomAppWebView.tsx index 67d76048a..faa33d332 100644 --- a/formulus/src/components/CustomAppWebView.tsx +++ b/formulus/src/components/CustomAppWebView.tsx @@ -68,19 +68,27 @@ const CustomAppWebView = forwardRef(null); const hasLoadedOnceRef = useRef(false); - // JS injection: load script from assets and prepend consoleLogScript - const [injectionScript, setInjectionScript] = useState(consoleLogScript); + const [injectionScript, setInjectionScript] = + useState(consoleLogScript); const injectionScriptRef = useRef(consoleLogScript); + const [isScriptReady, setIsScriptReady] = useState(false); + useEffect(() => { const loadScript = async () => { try { const script = await readFileAssets(INJECTION_SCRIPT_PATH); - const fullScript = consoleLogScript + '\n' + script + '\n(function() {console.debug("Injection scripts initialized");}())'; + const fullScript = + consoleLogScript + + '\n' + + script + + '\n(function() {console.debug("Injection scripts initialized");}())'; injectionScriptRef.current = fullScript; setInjectionScript(fullScript); + setIsScriptReady(true); } catch (err) { - injectionScriptRef.current = consoleLogScript; // fallback - setInjectionScript(consoleLogScript); // fallback + injectionScriptRef.current = consoleLogScript; + setInjectionScript(consoleLogScript); + setIsScriptReady(true); console.warn('Failed to load injection script:', err); } }; @@ -89,20 +97,28 @@ const CustomAppWebView = forwardRef { const manager = new FormulusWebViewMessageManager(webViewRef, appName); - + // Extend the message manager to handle API recovery requests const originalHandleMessage = manager.handleWebViewMessage; - manager.handleWebViewMessage = (event) => { + manager.handleWebViewMessage = event => { try { const eventData = JSON.parse(event.nativeEvent.data); - + // Handle API re-injection requests from WebView if (eventData.type === 'requestApiReinjection') { - console.log(`[CustomAppWebView - ${appName || 'Default'}] WebView requested API re-injection`); - + console.log( + `[CustomAppWebView - ${ + appName || 'Default' + }] WebView requested API re-injection`, + ); + // Perform immediate re-injection const latestScript = injectionScriptRef.current; - if (webViewRef.current && latestScript !== consoleLogScript && hasLoadedOnceRef.current) { + if ( + webViewRef.current && + latestScript !== consoleLogScript && + hasLoadedOnceRef.current + ) { const reInjectionWrapper = ` (function() { console.debug('[CustomAppWebView/ApiRecovery] Processing re-injection request...'); @@ -127,26 +143,33 @@ const CustomAppWebView = forwardRef ({ - reload: () => webViewRef.current?.reload?.(), - goBack: () => webViewRef.current?.goBack?.(), - goForward: () => webViewRef.current?.goForward?.(), - injectJavaScript: (script: string) => webViewRef.current?.injectJavaScript(script), - sendFormInit: (formData: FormInitData) => messageManager.sendFormInit(formData), - sendAttachmentData: (attachmentData: any) => messageManager.sendAttachmentData(attachmentData), - }), [messageManager]); + useImperativeHandle( + ref, + () => ({ + reload: () => webViewRef.current?.reload?.(), + goBack: () => webViewRef.current?.goBack?.(), + goForward: () => webViewRef.current?.goForward?.(), + injectJavaScript: (script: string) => + webViewRef.current?.injectJavaScript(script), + sendFormInit: (formData: FormInitData) => + messageManager.sendFormInit(formData), + sendAttachmentData: (attachmentData: any) => + messageManager.sendAttachmentData(attachmentData), + }), + [messageManager], + ); const handleError = (syntheticEvent: any) => { - const { nativeEvent } = syntheticEvent; + const {nativeEvent} = syntheticEvent; console.error('WebView error:', nativeEvent); }; @@ -154,7 +177,13 @@ const CustomAppWebView = forwardRef { // Ensure webViewRef.current and injectionScript (the fully prepared script) are available, and initial load has completed. - if (isFocused && webViewRef.current && injectionScript !== consoleLogScript && hasLoadedOnceRef.current) { // Check injectionScript is loaded and initial load done + if ( + isFocused && + webViewRef.current && + injectionScript !== consoleLogScript && + hasLoadedOnceRef.current + ) { + // Check injectionScript is loaded and initial load done const reInjectionWrapper = ` (function() { if (typeof window.formulus === 'undefined' && typeof globalThis.formulus === 'undefined') { @@ -181,16 +210,24 @@ const CustomAppWebView = forwardRef { const handleAppStateChange = (nextAppState: string) => { if (nextAppState === 'active') { - console.log('[CustomAppWebView] App became active, triggering handleReceiveFocus'); + console.log( + '[CustomAppWebView] App became active, triggering handleReceiveFocus', + ); // Call handleReceiveFocus on the messageManager when app becomes active - if (messageManager && typeof messageManager.handleReceiveFocus === 'function') { + if ( + messageManager && + typeof messageManager.handleReceiveFocus === 'function' + ) { messageManager.handleReceiveFocus(); } } }; - const subscription = AppState.addEventListener('change', handleAppStateChange); - + const subscription = AppState.addEventListener( + 'change', + handleAppStateChange, + ); + return () => { subscription?.remove(); }; @@ -199,35 +236,53 @@ const CustomAppWebView = forwardRef + + + ); + } + return ( console.debug(`[CustomAppWebView - ${appName || 'Default'}] Starting to load URL:`, appUrl)} + onLoadStart={() => + console.debug( + `[CustomAppWebView - ${appName || 'Default'}] Starting to load URL:`, + appUrl, + ) + } onLoadEnd={() => { - console.debug(`[CustomAppWebView - ${appName || 'Default'}] Finished loading URL: ${appUrl}`); + console.debug( + `[CustomAppWebView - ${ + appName || 'Default' + }] Finished loading URL: ${appUrl}`, + ); if (webViewRef.current) { - // Call window.onFormulusReady if it exists in the WebView - const scriptToNotifyReady = ` - if (typeof window.onFormulusReady === 'function') { - console.log('[CustomAppWebView Native] Calling window.onFormulusReady() in WebView.'); - window.onFormulusReady(); - } else { - console.debug('[CustomAppWebView Native] window.onFormulusReady is not defined in WebView. This is expected for most custom apps using getFormulus().'); - } + // Ensure API is available after load + const ensureApiScript = ` + (function() { + if (typeof window.formulus === 'undefined' && typeof globalThis.formulus !== 'undefined') { + window.formulus = globalThis.formulus; + } + if (typeof window.onFormulusReady === 'function') { + window.onFormulusReady(); + } + })(); `; - webViewRef.current.injectJavaScript(scriptToNotifyReady); + webViewRef.current.injectJavaScript(ensureApiScript); } - // Call the propagated onLoadEnd prop if it exists - hasLoadedOnceRef.current = true; // Mark that initial load has finished + hasLoadedOnceRef.current = true; if (onLoadEndProp) { onLoadEndProp(); } }} - onHttpError={(syntheticEvent) => { - const { nativeEvent } = syntheticEvent; + onHttpError={syntheticEvent => { + const {nativeEvent} = syntheticEvent; console.error('CustomWebView HTTP error:', nativeEvent); }} injectedJavaScriptBeforeContentLoaded={injectionScript} @@ -236,11 +291,13 @@ const CustomAppWebView = forwardRef ( - + )} diff --git a/formulus/src/components/MenuDrawer.tsx b/formulus/src/components/MenuDrawer.tsx new file mode 100644 index 000000000..63fc154fe --- /dev/null +++ b/formulus/src/components/MenuDrawer.tsx @@ -0,0 +1,315 @@ +import React, {useState, useEffect} from 'react'; +import { + View, + Text, + StyleSheet, + Modal, + TouchableOpacity, + ScrollView, + SafeAreaView, +} from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import {getUserInfo, UserInfo, UserRole} from '../api/synkronus/Auth'; + +interface MenuItem { + icon: string; + label: string; + screen: string; + minRole?: UserRole; // Minimum role required to see this item +} + +interface MenuDrawerProps { + visible: boolean; + onClose: () => void; + onNavigate: (screen: string) => void; + onLogout: () => void; + allowClose?: boolean; +} + +const ROLE_LEVELS: Record = { + 'read-only': 1, + 'read-write': 2, + admin: 3, +}; + +const hasMinRole = ( + userRole: UserRole | undefined, + minRole: UserRole, +): boolean => { + if (!userRole) return false; + return ROLE_LEVELS[userRole] >= ROLE_LEVELS[minRole]; +}; + +const MenuDrawer: React.FC = ({ + visible, + onClose, + onNavigate, + onLogout, + allowClose = true, +}) => { + const [userInfo, setUserInfo] = useState(null); + + useEffect(() => { + if (visible) { + getUserInfo().then(setUserInfo); + } + }, [visible]); + + const menuItems: MenuItem[] = [ + { + icon: 'clipboard-list', + label: 'Form Management', + screen: 'FormManagement', + minRole: 'admin', + }, + { + icon: 'cog', + label: 'App Settings', + screen: 'Settings', + }, + { + icon: 'information', + label: 'About', + screen: 'About', + }, + { + icon: 'help-circle', + label: 'Help & Support', + screen: 'Help', + }, + ]; + + const visibleItems = menuItems.filter(item => { + if (!item.minRole) return true; + return hasMinRole(userInfo?.role, item.minRole); + }); + + const getRoleBadgeStyle = (role: UserRole) => { + switch (role) { + case 'admin': + return styles.roleBadgeAdmin; + case 'read-write': + return styles.roleBadgeReadWrite; + default: + return styles.roleBadgeReadOnly; + } + }; + + return ( + + + {allowClose && ( + + )} + + + + Menu + {allowClose && ( + + + + )} + + + {/* User Info Section */} + {userInfo ? ( + + + + + + {userInfo.username} + + {userInfo.role} + + + + ) : ( + + + + + + Not logged in + Go to Settings to login + + + )} + + + {visibleItems.map((item, index) => ( + onNavigate(item.screen)}> + + {item.label} + + ))} + + + {userInfo && ( + + + + Logout + + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + }, + backdrop: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + drawer: { + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: '80%', + maxWidth: 320, + backgroundColor: '#FFFFFF', + shadowColor: '#000', + shadowOffset: {width: -2, height: 0}, + shadowOpacity: 0.25, + shadowRadius: 8, + elevation: 8, + }, + safeArea: { + flex: 1, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#E5E5E5', + }, + headerTitle: { + fontSize: 20, + fontWeight: 'bold', + color: '#000000', + }, + closeButton: { + padding: 4, + }, + userSection: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + backgroundColor: '#f8f8f8', + borderBottomWidth: 1, + borderBottomColor: '#E5E5E5', + }, + userAvatar: { + width: 50, + height: 50, + borderRadius: 25, + backgroundColor: '#007AFF', + justifyContent: 'center', + alignItems: 'center', + }, + userAvatarInactive: { + backgroundColor: '#ddd', + }, + userInfo: { + marginLeft: 12, + flex: 1, + }, + userName: { + fontSize: 16, + fontWeight: '600', + color: '#333', + marginBottom: 4, + }, + userNameInactive: { + fontSize: 16, + fontWeight: '600', + color: '#999', + marginBottom: 4, + }, + loginHint: { + fontSize: 12, + color: '#666', + }, + roleBadge: { + alignSelf: 'flex-start', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, + }, + roleBadgeAdmin: { + backgroundColor: '#FF3B30', + }, + roleBadgeReadWrite: { + backgroundColor: '#007AFF', + }, + roleBadgeReadOnly: { + backgroundColor: '#8E8E93', + }, + roleBadgeText: { + color: '#fff', + fontSize: 11, + fontWeight: '600', + }, + menuList: { + flex: 1, + }, + menuItem: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#F5F5F5', + }, + menuLabel: { + flex: 1, + marginLeft: 16, + fontSize: 16, + color: '#333333', + }, + footer: { + borderTopWidth: 1, + borderTopColor: '#E5E5E5', + padding: 16, + }, + logoutButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + }, + logoutText: { + marginLeft: 16, + fontSize: 16, + color: '#FF3B30', + fontWeight: '500', + }, +}); + +export default MenuDrawer; diff --git a/formulus/src/components/common/Button.tsx b/formulus/src/components/common/Button.tsx new file mode 100644 index 000000000..a240f52cc --- /dev/null +++ b/formulus/src/components/common/Button.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { TouchableOpacity, Text, StyleSheet, ActivityIndicator, ViewStyle, TextStyle } from 'react-native'; + +export interface ButtonProps { + title: string; + onPress: () => void; + variant?: 'primary' | 'secondary' | 'tertiary'; + size?: 'small' | 'medium' | 'large'; + disabled?: boolean; + loading?: boolean; + fullWidth?: boolean; + style?: ViewStyle; + textStyle?: TextStyle; + testID?: string; + accessibilityLabel?: string; +} + +const Button: React.FC = ({ + title, + onPress, + variant = 'primary', + size = 'medium', + disabled = false, + loading = false, + fullWidth = false, + style, + textStyle, + testID, + accessibilityLabel, +}) => { + const buttonStyle = [ + styles.button, + styles[`button_${variant}`], + styles[`button_${size}`], + fullWidth && styles.button_fullWidth, + (disabled || loading) && styles.button_disabled, + style, + ]; + + const textStyles = [ + styles.text, + styles[`text_${variant}`], + styles[`text_${size}`], + textStyle, + ]; + + return ( + + {loading ? ( + + ) : ( + {title} + )} + + ); +}; + +const styles = StyleSheet.create({ + button: { + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + }, + button_primary: { + backgroundColor: '#007AFF', + }, + button_secondary: { + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: '#007AFF', + }, + button_tertiary: { + backgroundColor: 'transparent', + }, + button_small: { + paddingVertical: 8, + paddingHorizontal: 16, + minHeight: 36, + }, + button_medium: { + paddingVertical: 12, + paddingHorizontal: 24, + minHeight: 48, + }, + button_large: { + paddingVertical: 16, + paddingHorizontal: 32, + minHeight: 56, + }, + button_fullWidth: { + width: '100%', + }, + button_disabled: { + opacity: 0.5, + }, + text: { + fontWeight: '600', + textAlign: 'center', + }, + text_primary: { + color: '#FFFFFF', + }, + text_secondary: { + color: '#007AFF', + }, + text_tertiary: { + color: '#007AFF', + }, + text_small: { + fontSize: 14, + }, + text_medium: { + fontSize: 16, + }, + text_large: { + fontSize: 18, + }, +}); + +export default Button; + diff --git a/formulus/src/components/common/Input.tsx b/formulus/src/components/common/Input.tsx new file mode 100644 index 000000000..3e17161d0 --- /dev/null +++ b/formulus/src/components/common/Input.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { TextInput, Text, View, StyleSheet, TextInputProps, ViewStyle } from 'react-native'; + +export interface InputProps extends TextInputProps { + label?: string; + error?: string; + containerStyle?: ViewStyle; + testID?: string; +} + +const Input: React.FC = ({ + label, + error, + containerStyle, + testID, + style, + ...textInputProps +}) => { + return ( + + {label && {label}} + + {error && {error}} + + ); +}; + +const styles = StyleSheet.create({ + container: { + marginBottom: 16, + }, + label: { + fontSize: 14, + fontWeight: '500', + color: '#333333', + marginBottom: 8, + }, + input: { + height: 48, + borderWidth: 1, + borderColor: '#CCCCCC', + borderRadius: 8, + paddingHorizontal: 12, + fontSize: 16, + color: '#000000', + backgroundColor: '#FFFFFF', + }, + inputError: { + borderColor: '#FF3B30', + }, + errorText: { + fontSize: 12, + color: '#FF3B30', + marginTop: 4, + }, +}); + +export default Input; + diff --git a/formulus/src/components/common/index.ts b/formulus/src/components/common/index.ts new file mode 100644 index 000000000..82d3b6aa9 --- /dev/null +++ b/formulus/src/components/common/index.ts @@ -0,0 +1,2 @@ +export { default as Button } from './Button'; +export { default as Input } from './Input'; diff --git a/formulus/src/navigation/MainAppNavigator.tsx b/formulus/src/navigation/MainAppNavigator.tsx new file mode 100644 index 000000000..08c588016 --- /dev/null +++ b/formulus/src/navigation/MainAppNavigator.tsx @@ -0,0 +1,67 @@ +import React, {useEffect, useState} from 'react'; +import {createStackNavigator} from '@react-navigation/stack'; +import {useFocusEffect} from '@react-navigation/native'; +import MainTabNavigator from './MainTabNavigator'; +import WelcomeScreen from '../screens/WelcomeScreen'; +import SettingsScreen from '../screens/SettingsScreen'; +import FormManagementScreen from '../screens/FormManagementScreen'; +import {MainAppStackParamList} from '../types/NavigationTypes'; +import {serverConfigService} from '../services/ServerConfigService'; + +const Stack = createStackNavigator(); + +const MainAppNavigator: React.FC = () => { + const [isConfigured, setIsConfigured] = useState(null); + + const checkConfiguration = async () => { + const serverUrl = await serverConfigService.getServerUrl(); + setIsConfigured(!!serverUrl); + }; + + useEffect(() => { + checkConfiguration(); + }, []); + + useFocusEffect( + React.useCallback(() => { + checkConfiguration(); + }, []), + ); + + if (isConfigured === null) { + return null; + } + + return ( + + + + + + + ); +}; + +export default MainAppNavigator; diff --git a/formulus/src/navigation/MainTabNavigator.tsx b/formulus/src/navigation/MainTabNavigator.tsx new file mode 100644 index 000000000..2362e42dd --- /dev/null +++ b/formulus/src/navigation/MainTabNavigator.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import HomeScreen from '../screens/HomeScreen'; +import FormsScreen from '../screens/FormsScreen'; +import ObservationsScreen from '../screens/ObservationsScreen'; +import SyncScreen from '../screens/SyncScreen'; +import MoreScreen from '../screens/MoreScreen'; + +const Tab = createBottomTabNavigator(); + +const MainTabNavigator: React.FC = () => { + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + listeners={({navigation, route}) => ({ + tabPress: e => { + const state = navigation.getState(); + const currentRoute = state.routes[state.index]; + if (currentRoute?.name === 'More') { + navigation.setParams({openDrawer: Date.now()}); + } + }, + })} + /> + + ); +}; + +export default MainTabNavigator; + diff --git a/formulus/src/screens/FormsScreen.tsx b/formulus/src/screens/FormsScreen.tsx new file mode 100644 index 000000000..e8a774c2a --- /dev/null +++ b/formulus/src/screens/FormsScreen.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import {View, Text, StyleSheet, SafeAreaView} from 'react-native'; + +const FormsScreen: React.FC = () => { + return ( + + + Forms + Forms will be displayed here + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F0F2F5', + }, + content: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + color: '#333333', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + color: '#666666', + }, +}); + +export default FormsScreen; diff --git a/formulus/src/screens/HomeScreen.tsx b/formulus/src/screens/HomeScreen.tsx index d30bb73ad..3a0098264 100644 --- a/formulus/src/screens/HomeScreen.tsx +++ b/formulus/src/screens/HomeScreen.tsx @@ -1,69 +1,44 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { StyleSheet, View, ActivityIndicator, TouchableOpacity, Text, Platform, Alert } from 'react-native'; +import React, {useEffect, useState, useRef} from 'react'; +import {StyleSheet, View, ActivityIndicator, Platform} from 'react-native'; import RNFS from 'react-native-fs'; -import CustomAppWebView, { CustomAppWebViewHandle } from '../components/CustomAppWebView'; -import { appEvents } from '../webview/FormulusMessageHandlers'; // Import appEvents -import { FormService } from '../services/FormService'; +import CustomAppWebView, { + CustomAppWebViewHandle, +} from '../components/CustomAppWebView'; -const HomeScreen = ({ navigation }: any) => { +const HomeScreen = ({navigation}: any) => { const [localUri, setLocalUri] = useState(null); const [isLoading, setIsLoading] = useState(true); const customAppRef = useRef(null); - useEffect(() => { - const setupPlaceholder = async () => { - try { - const filePath = `${RNFS.DocumentDirectoryPath}/app/index.html`; - const fileExists = await RNFS.exists(filePath); - if (!fileExists) { - // USE PLACEHOLDER - const placeholderUri = Platform.OS === 'android' + const checkAndSetAppUri = async () => { + try { + const filePath = `${RNFS.DocumentDirectoryPath}/app/index.html`; + const fileExists = await RNFS.exists(filePath); + if (!fileExists) { + const placeholderUri = + Platform.OS === 'android' ? 'file:///android_asset/webview/placeholder_app.html' - : 'file:///webview/placeholder_app.html'; // Add iOS path - console.log('Using placeholder HTML at:', placeholderUri); - setLocalUri(placeholderUri); - } else { - console.log('Using custom app HTML at:', filePath); - setLocalUri(`file://${filePath}`); - } - } catch (err) { - console.warn('Failed to setup placeholder HTML:', err); + : 'file:///webview/placeholder_app.html'; + setLocalUri(placeholderUri); + } else { + setLocalUri(`file://${filePath}`); } - }; - - setupPlaceholder(); + } catch (err) { + console.warn('Failed to setup app URI:', err); + } + }; + + useEffect(() => { + checkAndSetAppUri(); }, []); useEffect(() => { - console.log('HomeScreen: MOUNTED'); // Added for debugging mount/unmount - - // Subscribe to FormService cache invalidation to refresh form specs - let unsubscribeFromCache: (() => void) | null = null; - - const initCacheSubscription = async () => { - try { - const formService = await FormService.getInstance(); - unsubscribeFromCache = formService.onCacheInvalidated(() => { - console.log('HomeScreen: FormService cache invalidated, form specs refreshed'); - }); - } catch (error) { - console.error('HomeScreen: Failed to subscribe to FormService cache invalidation:', error); - } - }; - - initCacheSubscription(); - - return () => { - console.log('HomeScreen: UNMOUNTING'); // Added for debugging mount/unmount - - // Cleanup FormService cache subscription - if (unsubscribeFromCache) { - unsubscribeFromCache(); - } - }; - }, []); // Empty dependency array ensures this runs once on mount and cleans up on unmount + const unsubscribe = navigation.addListener('focus', () => { + checkAndSetAppUri(); + }); + return unsubscribe; + }, [navigation]); - // Update isLoading when localUri is set useEffect(() => { if (localUri) { setIsLoading(false); @@ -78,30 +53,18 @@ const HomeScreen = ({ navigation }: any) => { ); } - const handleClick = () => { - console.log('HomeScreen: handleClick event received'); - - console.log('HomeScreen: handleClick event received'); - const bgPath = "file:///data/user/0/com.formulus/files/app/assets/sapiens-Dt1gTJ5Q.jpg"; - // Check if bgPath exists - RNFS.exists(bgPath).then((exists) => { - if (exists) { - console.log('Background image exists at:', bgPath); - } else { - console.log('Background image does not exist at:', bgPath); - } - }); - }; - return ( {isLoading ? ( - + ) : ( - /* Main WebView for custom app using our new component */ )} @@ -113,38 +76,11 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - webview: { - flex: 1, - }, loading: { flex: 1, justifyContent: 'center', alignItems: 'center', }, - testButton: { - position: 'absolute', - right: 20, - bottom: 20, - backgroundColor: '#4A90E2', - paddingHorizontal: 20, - paddingVertical: 10, - borderRadius: 5, - elevation: 3, - }, - adminButton: { - position: 'absolute', - right: 20, - bottom: 80, - backgroundColor: '#9C27B0', // Purple color for admin button - paddingHorizontal: 20, - paddingVertical: 10, - borderRadius: 5, - elevation: 3, - }, - testButtonText: { - color: 'white', - fontWeight: 'bold', - }, }); export default HomeScreen; diff --git a/formulus/src/screens/LoginScreen.tsx b/formulus/src/screens/LoginScreen.tsx deleted file mode 100644 index 4f9467cc4..000000000 --- a/formulus/src/screens/LoginScreen.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; - -const LoginScreen = () => { - return ( - - Login Screen Placeholder - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#f0f0f0', - }, - text: { - fontSize: 20, - color: '#888', - }, -}); - -export default LoginScreen; diff --git a/formulus/src/screens/MoreScreen.tsx b/formulus/src/screens/MoreScreen.tsx new file mode 100644 index 000000000..1374b9063 --- /dev/null +++ b/formulus/src/screens/MoreScreen.tsx @@ -0,0 +1,93 @@ +import React, {useState, useEffect} from 'react'; +import {StyleSheet, SafeAreaView, Alert} from 'react-native'; +import { + useFocusEffect, + useRoute, + useNavigation, + CompositeNavigationProp, +} from '@react-navigation/native'; +import {BottomTabNavigationProp} from '@react-navigation/bottom-tabs'; +import {StackNavigationProp} from '@react-navigation/stack'; +import { + MainTabParamList, + MainAppStackParamList, +} from '../types/NavigationTypes'; +import MenuDrawer from '../components/MenuDrawer'; +import {logout} from '../api/synkronus/Auth'; + +type MoreScreenNavigationProp = CompositeNavigationProp< + BottomTabNavigationProp, + StackNavigationProp +>; + +const MoreScreen: React.FC = () => { + const [drawerVisible, setDrawerVisible] = useState(false); + const route = useRoute(); + const navigation = useNavigation(); + + useFocusEffect( + React.useCallback(() => { + setDrawerVisible(true); + }, []), + ); + + useEffect(() => { + const params = route.params as {openDrawer?: number} | undefined; + if (params?.openDrawer) { + setDrawerVisible(true); + } + }, [route.params]); + + const handleNavigate = (screen: string) => { + setDrawerVisible(false); + // Navigate to screens in the MainAppStack + if (screen === 'Settings' || screen === 'FormManagement') { + navigation.navigate(screen as keyof MainAppStackParamList); + } else { + // Other screens not yet implemented - stay on Home for now + console.log('Navigate to:', screen, '(not yet implemented)'); + navigation.navigate('Home'); + } + }; + + const handleLogout = () => { + Alert.alert('Logout', 'Are you sure you want to logout?', [ + {text: 'Cancel', style: 'cancel'}, + { + text: 'Logout', + style: 'destructive', + onPress: async () => { + await logout(); + setDrawerVisible(false); + navigation.navigate('Home'); + }, + }, + ]); + }; + + const handleClose = () => { + setDrawerVisible(false); + navigation.navigate('Home'); + }; + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#FFFFFF', + }, +}); + +export default MoreScreen; diff --git a/formulus/src/screens/ObservationsScreen.tsx b/formulus/src/screens/ObservationsScreen.tsx new file mode 100644 index 000000000..60d507142 --- /dev/null +++ b/formulus/src/screens/ObservationsScreen.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import {View, Text, StyleSheet, SafeAreaView} from 'react-native'; + +const ObservationsScreen: React.FC = () => { + return ( + + + Observations + Observations will be displayed here + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F0F2F5', + }, + content: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + color: '#333333', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + color: '#666666', + }, +}); + +export default ObservationsScreen; diff --git a/formulus/src/screens/ProfileScreen.tsx b/formulus/src/screens/ProfileScreen.tsx deleted file mode 100644 index fdb0fec70..000000000 --- a/formulus/src/screens/ProfileScreen.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; - -const ProfileScreen = () => { - return ( - - Profile Screen Placeholder - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#f0f0f0', - }, - text: { - fontSize: 20, - color: '#888', - }, -}); - -export default ProfileScreen; diff --git a/formulus/src/screens/RegisterScreen.tsx b/formulus/src/screens/RegisterScreen.tsx deleted file mode 100644 index c27eefa8c..000000000 --- a/formulus/src/screens/RegisterScreen.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; - -const RegisterScreen = () => { - return ( - - Register Screen Placeholder - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#f0f0f0', - }, - text: { - fontSize: 20, - color: '#888', - }, -}); - -export default RegisterScreen; diff --git a/formulus/src/screens/SettingsScreen.tsx b/formulus/src/screens/SettingsScreen.tsx index 60a5df0b9..d8c9c5a7c 100644 --- a/formulus/src/screens/SettingsScreen.tsx +++ b/formulus/src/screens/SettingsScreen.tsx @@ -1,158 +1,205 @@ -import React, { useState, useEffect } from 'react'; -import {View,Text,TextInput,TouchableOpacity,StyleSheet,SafeAreaView,ScrollView,Alert,ActivityIndicator} from 'react-native'; +import React, {useState, useEffect} from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + SafeAreaView, + ScrollView, + Alert, + ActivityIndicator, +} from 'react-native'; +import {useNavigation} from '@react-navigation/native'; +import {StackNavigationProp} from '@react-navigation/stack'; import * as Keychain from 'react-native-keychain'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { login } from '../api/synkronus/Auth'; +import {login, getUserInfo, logout, UserInfo} from '../api/synkronus/Auth'; +import {serverConfigService} from '../services/ServerConfigService'; import QRScannerModal from '../components/QRScannerModal'; -import { QRSettingsService } from '../services/QRSettingsService'; +import {QRSettingsService} from '../services/QRSettingsService'; +import {MainAppStackParamList} from '../types/NavigationTypes'; + +type SettingsScreenNavigationProp = StackNavigationProp< + MainAppStackParamList, + 'Settings' +>; const SettingsScreen = () => { + const navigation = useNavigation(); const [serverUrl, setServerUrl] = useState(''); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const [isSaving, setIsSaving] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isTesting, setIsTesting] = useState(false); const [isLoggingIn, setIsLoggingIn] = useState(false); const [showQRScanner, setShowQRScanner] = useState(false); + const [loggedInUser, setLoggedInUser] = useState(null); - // Load settings when component mounts useEffect(() => { - const loadSettings = async () => { - try { - const settings = await AsyncStorage.getItem('@settings'); - if (settings) { - const { serverUrl: savedUrl } = JSON.parse(settings); - setServerUrl(savedUrl || ''); - } - } catch (error) { - console.error('Failed to load settings', error); - } finally { - setIsLoading(false); + loadSettings(); + }, []); + + const loadSettings = async () => { + try { + const savedUrl = await serverConfigService.getServerUrl(); + if (savedUrl) { + setServerUrl(savedUrl); } - const credentials = await loadCredentials(); + + const credentials = await Keychain.getGenericPassword(); if (credentials) { setUsername(credentials.username); setPassword(credentials.password); } - }; - - loadSettings(); - }, []); - const handleLogin = async () => { - if (!username || !password) { - Alert.alert('Error', 'Username and password are required') - return - } - try { - setIsLoggingIn(true) - await login(username, password) - Alert.alert('Success', 'Login authenticated successfully.\nYou can now sync your app and data!') - } catch (err) { - console.error('Login failed', err) + // Check if user is logged in + const userInfo = await getUserInfo(); + setLoggedInUser(userInfo); + } catch (error) { + console.error('Failed to load settings:', error); } finally { - setIsLoggingIn(false) + setIsLoading(false); } - } + }; - async function resetCredentials() { - try { - await Keychain.resetGenericPassword(); - console.log('Credentials reset successfully.'); - } catch (error) { - console.error('Keychain couldn\'t be accessed!', error); + const handleTestConnection = async () => { + if (!serverUrl.trim()) { + Alert.alert('Error', 'Please enter a server URL'); + return; } - } - async function loadCredentials(): Promise<{ username: string, password: string } | null> { - try { - const credentials = await Keychain.getGenericPassword(); - if (credentials) { - console.log('Credentials loaded successfully:', credentials.username, "password redacted"); //credentials.password - return {username: credentials.username, password: credentials.password}; - } else { - console.log('No credentials stored.'); - return null; - } - } catch (error) { - console.error('Keychain couldn\'t be accessed!', error); - return null; - } - } - async function saveCredentials(username: string, password: string) { + setIsTesting(true); try { - await Keychain.setGenericPassword(username, password); - console.log('Credentials saved successfully!'); - } catch (error) { - console.error('Keychain couldn\'t be accessed!', error); - throw new Error('Keychain couldn\'t be accessed for safe credential storage!'); + const result = await serverConfigService.testConnection(serverUrl); + Alert.alert(result.success ? 'Success' : 'Error', result.message); + } finally { + setIsTesting(false); } - } - + }; + const handleSave = async () => { - // Prevent saving if only one of username/password is provided if ((username && !password) || (!username && password)) { - Alert.alert('Error', 'Both username and password are required to save credentials'); + Alert.alert('Error', 'Both username and password are required'); return; } + setIsSaving(true); try { - setIsSaving(true); - - // Save settings to AsyncStorage - await AsyncStorage.setItem('@settings', JSON.stringify({ - serverUrl - })); - - // Save username and password in keychain + await serverConfigService.saveServerUrl(serverUrl); + if (username && password) { - await saveCredentials(username, password); + await Keychain.setGenericPassword(username, password); } else { - await resetCredentials(); + await Keychain.resetGenericPassword(); } - - Alert.alert('Success', 'Settings saved successfully'); + + Alert.alert('Success', 'Settings saved'); } catch (error) { - console.error('Failed to save settings', error); + console.error('Failed to save settings:', error); Alert.alert('Error', 'Failed to save settings'); } finally { setIsSaving(false); } }; - const handleQRScan = () => { - setShowQRScanner(true); + const handleLogin = async () => { + if (!serverUrl.trim()) { + Alert.alert('Error', 'Server URL is required'); + return; + } + if (!username || !password) { + Alert.alert('Error', 'Username and password are required'); + return; + } + + setIsLoggingIn(true); + try { + // Save settings before login + await serverConfigService.saveServerUrl(serverUrl); + await Keychain.setGenericPassword(username, password); + + const userInfo = await login(username, password); + setLoggedInUser(userInfo); + Alert.alert( + 'Success', + `Logged in as ${userInfo.username} (${userInfo.role})\nYou can now sync your app and data.`, + [{text: 'OK', onPress: () => navigation.navigate('MainApp')}], + ); + } catch (error: any) { + console.error('Login failed:', error); + const message = + error?.response?.data?.message || error?.message || 'Login failed'; + Alert.alert('Login Failed', message); + } finally { + setIsLoggingIn(false); + } + }; + + const handleLogout = async () => { + Alert.alert('Logout', 'Are you sure you want to logout?', [ + {text: 'Cancel', style: 'cancel'}, + { + text: 'Logout', + style: 'destructive', + onPress: async () => { + await logout(); + setLoggedInUser(null); + Alert.alert('Success', 'Logged out successfully'); + }, + }, + ]); }; const handleQRResult = async (result: any) => { - console.log('QR scan result:', result); - + setShowQRScanner(false); + if (result.status === 'success' && result.data?.value) { try { - const settings = await QRSettingsService.processQRCode(result.data.value); - - // Update the UI with the new settings + const settings = await QRSettingsService.processQRCode( + result.data.value, + ); setServerUrl(settings.serverUrl); setUsername(settings.username); setPassword(settings.password); - - Alert.alert('Success', 'Settings updated from QR code'); + + // Auto-login after QR scan + try { + const userInfo = await login(settings.username, settings.password); + setLoggedInUser(userInfo); + Alert.alert( + 'Success', + 'Settings updated and logged in successfully', + [{text: 'OK', onPress: () => navigation.navigate('MainApp')}], + ); + } catch (error: any) { + Alert.alert( + 'Settings Updated', + 'QR code processed. Login failed - please check credentials.', + ); + } } catch (error) { - console.error('QR processing error:', error); - Alert.alert('Error', `Failed to process QR code: ${error instanceof Error ? error.message : 'Unknown error'}`); + Alert.alert('Error', 'Failed to process QR code'); } - } else if (result.status === 'cancelled') { - console.log('QR scan cancelled'); - } else { + } else if (result.status !== 'cancelled') { Alert.alert('Error', result.message || 'Failed to scan QR code'); } - - setShowQRScanner(false); + }; + + const getRoleBadgeStyle = (role: string) => { + switch (role) { + case 'admin': + return styles.roleBadgeAdmin; + case 'read-write': + return styles.roleBadgeReadWrite; + default: + return styles.roleBadgeReadOnly; + } }; if (isLoading) { return ( - + ); @@ -160,15 +207,34 @@ const SettingsScreen = () => { return ( - - Settings - - + + {/* Login Status Card */} + {loggedInUser && ( + + + Logged In + + {loggedInUser.role} + + + {loggedInUser.username} + + Logout + + + )} + - Synkronus URL + Synkronus Server URL { keyboardType="url" autoCorrect={false} /> + + + {isTesting ? 'Testing...' : 'Test Connection'} + + + + Username { secureTextEntry autoCapitalize="none" autoCorrect={false} - autoComplete="password" - textContentType="password" /> - - 📱 Scan QR Code + onPress={() => setShowQRScanner(true)}> + 📱 Scan QR Code - + disabled={isSaving}> {isSaving ? 'Saving...' : 'Save Settings'} - + disabled={isLoggingIn}> {isLoggingIn ? 'Logging in...' : 'Login'} - + setShowQRScanner(false)} @@ -255,25 +320,75 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#f5f5f5', }, - scrollContainer: { + centered: { + justifyContent: 'center', + alignItems: 'center', + }, + content: { padding: 20, }, - section: { - marginBottom: 24, + statusCard: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 20, + borderLeftWidth: 4, + borderLeftColor: '#34C759', + }, + statusHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + statusTitle: { + fontSize: 14, + color: '#34C759', + fontWeight: '600', }, - sectionHeader: { - fontSize: 20, + statusUsername: { + fontSize: 18, fontWeight: '600', - marginBottom: 16, color: '#333', + marginBottom: 12, + }, + roleBadge: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + }, + roleBadgeAdmin: { + backgroundColor: '#FF3B30', + }, + roleBadgeReadWrite: { + backgroundColor: '#007AFF', + }, + roleBadgeReadOnly: { + backgroundColor: '#8E8E93', + }, + roleBadgeText: { + color: '#fff', + fontSize: 12, + fontWeight: '600', + }, + logoutButton: { + alignSelf: 'flex-start', + }, + logoutButtonText: { + color: '#FF3B30', + fontSize: 14, + fontWeight: '500', + }, + section: { + marginBottom: 20, }, label: { fontSize: 16, + fontWeight: '500', marginBottom: 8, color: '#333', }, input: { - width: '100%', height: 50, borderWidth: 1, borderColor: '#ddd', @@ -281,31 +396,43 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, fontSize: 16, backgroundColor: '#fff', + color: '#000', + }, + divider: { + height: 1, + backgroundColor: '#ddd', + marginVertical: 16, }, button: { height: 50, borderRadius: 8, - backgroundColor: '#007AFF', + backgroundColor: '#666', justifyContent: 'center', alignItems: 'center', - marginTop: 8, + marginTop: 12, }, - buttonText: { - color: '#fff', - fontSize: 16, - fontWeight: '600', - }, - divider: { - height: 1, - backgroundColor: '#ccc', - marginVertical: 16, + primaryButton: { + height: 50, + borderRadius: 8, + backgroundColor: '#007AFF', + justifyContent: 'center', + alignItems: 'center', + marginTop: 12, }, - loadingContainer: { + secondaryButton: { + height: 40, + borderRadius: 8, + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: '#007AFF', justifyContent: 'center', alignItems: 'center', + marginTop: 8, }, - buttonDisabled: { - opacity: 0.6, + secondaryButtonText: { + color: '#007AFF', + fontSize: 14, + fontWeight: '500', }, qrButton: { height: 50, @@ -313,14 +440,16 @@ const styles = StyleSheet.create({ backgroundColor: '#34C759', justifyContent: 'center', alignItems: 'center', - marginTop: 8, - marginBottom: 8, + marginTop: 12, }, - qrButtonText: { + buttonText: { color: '#fff', fontSize: 16, fontWeight: '600', }, + disabled: { + opacity: 0.6, + }, }); -export default SettingsScreen; \ No newline at end of file +export default SettingsScreen; diff --git a/formulus/src/screens/SyncScreen.tsx b/formulus/src/screens/SyncScreen.tsx index 0f79641eb..5b76b8edf 100644 --- a/formulus/src/screens/SyncScreen.tsx +++ b/formulus/src/screens/SyncScreen.tsx @@ -14,37 +14,56 @@ import { syncService } from '../services/SyncService'; import { useSyncContext } from '../contexts/SyncContext'; import RNFS from 'react-native-fs'; import { databaseService } from '../database/DatabaseService'; +import {getUserInfo} from '../api/synkronus/Auth'; const SyncScreen = () => { - const { syncState, startSync, updateProgress, finishSync, cancelSync, clearError } = useSyncContext(); + const { + syncState, + startSync, + updateProgress, + finishSync, + cancelSync, + clearError, + } = useSyncContext(); const [lastSync, setLastSync] = useState(null); const [status, setStatus] = useState('Loading...'); const [updateAvailable, setUpdateAvailable] = useState(false); const [dataVersion, setDataVersion] = useState(0); - const [pendingUploads, setPendingUploads] = useState<{count: number, sizeMB: number}>({count: 0, sizeMB: 0}); + const [pendingUploads, setPendingUploads] = useState<{ + count: number; + sizeMB: number; + }>({count: 0, sizeMB: 0}); const [pendingObservations, setPendingObservations] = useState(0); - const [backgroundSyncEnabled, setBackgroundSyncEnabled] = useState(false); + const [backgroundSyncEnabled, setBackgroundSyncEnabled] = + useState(false); + const [isAdmin, setIsAdmin] = useState(false); + const [appBundleVersion, setAppBundleVersion] = useState('0'); + const [serverBundleVersion, setServerBundleVersion] = + useState('Unknown'); // Get pending upload info const updatePendingUploads = useCallback(async () => { try { const pendingUploadDirectory = `${RNFS.DocumentDirectoryPath}/attachments/pending_upload`; - + // Ensure directory exists await RNFS.mkdir(pendingUploadDirectory); - + // Get all files in pending_upload directory const files = await RNFS.readDir(pendingUploadDirectory); const attachmentFiles = files.filter(file => file.isFile()); - + const count = attachmentFiles.length; - const totalSizeBytes = attachmentFiles.reduce((sum, file) => sum + file.size, 0); + const totalSizeBytes = attachmentFiles.reduce( + (sum, file) => sum + file.size, + 0, + ); const sizeMB = totalSizeBytes / (1024 * 1024); - - setPendingUploads({ count, sizeMB }); + + setPendingUploads({count, sizeMB}); } catch (error) { console.error('Failed to get pending uploads info:', error); - setPendingUploads({ count: 0, sizeMB: 0 }); + setPendingUploads({count: 0, sizeMB: 0}); } }, []); @@ -63,7 +82,7 @@ const SyncScreen = () => { // Handle sync operations const handleSync = useCallback(async () => { if (syncState.isActive) return; // Prevent multiple syncs - + try { startSync(true); // Allow cancellation const version = await syncService.syncObservations(true); @@ -82,7 +101,7 @@ const SyncScreen = () => { // Handle app updates const handleCustomAppUpdate = useCallback(async () => { if (syncState.isActive) return; // Prevent multiple syncs - + try { startSync(false); // App updates can't be cancelled easily await syncService.updateAppBundle(); @@ -101,15 +120,28 @@ const SyncScreen = () => { try { const hasUpdate = await syncService.checkForUpdates(force); setUpdateAvailable(hasUpdate); + + // Get current app bundle version + const currentVersion = (await AsyncStorage.getItem('@appVersion')) || '0'; + setAppBundleVersion(currentVersion); + + // Get server bundle version + try { + const {synkronusApi} = await import('../api/synkronus/index'); + const manifest = await synkronusApi.getManifest(); + setServerBundleVersion(manifest.version); + } catch (err) { + // Server manifest unavailable + } } catch (error) { - console.warn('Failed to check for updates', error); + // Update check failed } }, []); // Initialize component useEffect(() => { // Set up status updates - const unsubscribe = syncService.subscribeToStatusUpdates((newStatus) => { + const unsubscribe = syncService.subscribeToStatusUpdates(newStatus => { setStatus(newStatus); }); @@ -117,19 +149,23 @@ const SyncScreen = () => { const initialize = async () => { await syncService.initialize(); await checkForUpdates(true); // Check for updates on initial load - + + // Check if user is admin + const userInfo = await getUserInfo(); + setIsAdmin(userInfo?.role === 'admin'); + // Get last sync time const lastSyncTime = await AsyncStorage.getItem('@lastSync'); if (lastSyncTime) { setLastSync(lastSyncTime); } - + // Get last seen version const lastSeenVersion = await AsyncStorage.getItem('@last_seen_version'); if (lastSeenVersion) { setDataVersion(parseInt(lastSeenVersion, 10)); } - + // Get pending uploads and observations info await updatePendingUploads(); await updatePendingObservations(); @@ -147,7 +183,7 @@ const SyncScreen = () => { Synchronization - + Status: {status} @@ -160,6 +196,13 @@ const SyncScreen = () => { + + App Bundle: + + Local: {appBundleVersion} | Server: {serverBundleVersion} + + + Pending Uploads: @@ -169,9 +212,7 @@ const SyncScreen = () => { Pending Observations: - - {pendingObservations} records - + {pendingObservations} records {/* Sync Progress Display */} @@ -182,21 +223,29 @@ const SyncScreen = () => { {syncState.progress.details || 'Syncing...'} - - {syncState.progress.current}/{syncState.progress.total} - {Math.round((syncState.progress.current / syncState.progress.total) * 100)}% + {syncState.progress.current}/{syncState.progress.total} -{' '} + {Math.round( + (syncState.progress.current / syncState.progress.total) * 100, + )} + % {syncState.canCancel && ( - + onPress={cancelSync}> Cancel Sync )} @@ -207,35 +256,43 @@ const SyncScreen = () => { {syncState.error && ( {syncState.error} - + onPress={clearError}> Dismiss )} - + disabled={syncState.isActive}> {syncState.isActive ? 'Syncing...' : 'Sync data + attachments'} - + disabled={syncState.isActive || (!updateAvailable && !isAdmin)}> - {syncState.isActive ? 'Syncing...' : 'Update forms and custom app'} + {syncState.isActive + ? 'Syncing...' + : 'Update forms and custom app'} + {!updateAvailable && !isAdmin && ( + + No updates available. Check your connection and try again. + + )} @@ -280,7 +337,7 @@ const styles = StyleSheet.create({ backgroundColor: 'white', borderRadius: 8, shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, + shadowOffset: {width: 0, height: 1}, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2, @@ -300,7 +357,7 @@ const styles = StyleSheet.create({ borderRadius: 8, padding: 16, shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, + shadowOffset: {width: 0, height: 1}, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2, @@ -416,6 +473,13 @@ const styles = StyleSheet.create({ fontSize: 12, fontWeight: '500', }, + hintText: { + fontSize: 12, + color: '#666', + textAlign: 'center', + marginTop: 4, + fontStyle: 'italic', + }, }); export default SyncScreen; diff --git a/formulus/src/screens/WelcomeScreen.tsx b/formulus/src/screens/WelcomeScreen.tsx index c2925e8db..bb17a2201 100644 --- a/formulus/src/screens/WelcomeScreen.tsx +++ b/formulus/src/screens/WelcomeScreen.tsx @@ -1,112 +1,86 @@ -import React, { useState } from 'react'; -import { View, Text, TextInput, StyleSheet, SafeAreaView, Alert, TouchableOpacity } from 'react-native'; -import { RootStackParamList } from '../types/NavigationTypes'; -import { StackNavigationProp } from '@react-navigation/stack'; -import { useNavigation } from '@react-navigation/native'; -import Icon from 'react-native-vector-icons/MaterialIcons'; +import React from 'react'; +import { + View, + Text, + Image, + StyleSheet, + TouchableOpacity, + SafeAreaView, +} from 'react-native'; +import {useNavigation} from '@react-navigation/native'; +import {StackNavigationProp} from '@react-navigation/stack'; +import {MainAppStackParamList} from '../types/NavigationTypes'; +type WelcomeScreenNavigationProp = StackNavigationProp; - -type WelcomeScreenNavigationProp = StackNavigationProp; - -const WelcomeScreen: React.FC = () => { +const WelcomeScreen = () => { const navigation = useNavigation(); - + const handleGetStarted = () => { + navigation.navigate('MainApp'); + }; return ( - Welcome to Formulus - - - navigation.navigate('Home')} - > - - Home - - - navigation.navigate('Settings')} - > - - Settings - - - navigation.navigate('FormManagement')} - > - - Form Management - - - navigation.navigate('Sync')} - > - - Sync + + + Welcome to Formulus + + Configure your server to get started + + + Get Started - ); }; const styles = StyleSheet.create({ container: { + flex: 1, + backgroundColor: '#ffffff', + }, + content: { flex: 1, justifyContent: 'center', alignItems: 'center', - padding: 24, - backgroundColor: '#fff', + padding: 40, + }, + logo: { + width: 200, + height: 200, + marginBottom: 40, }, title: { - fontSize: 24, + fontSize: 28, fontWeight: 'bold', - marginBottom: 32, + color: '#000000', + marginBottom: 12, + textAlign: 'center', }, - input: { - width: '100%', - height: 48, - borderColor: '#ccc', - borderWidth: 1, - borderRadius: 8, - paddingHorizontal: 12, - marginBottom: 16, + subtitle: { fontSize: 16, + color: '#666666', + marginBottom: 40, + textAlign: 'center', }, - buttonContainer: { - width: '100%', - marginTop: 8, - gap: 16, - }, - iconButton: { - backgroundColor: '#007AFF', - paddingVertical: 20, - paddingHorizontal: 24, - borderRadius: 12, - alignItems: 'center', - justifyContent: 'center', - flexDirection: 'column', - minHeight: 100, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.25, - shadowRadius: 3.84, - elevation: 5, + button: { + backgroundColor: '#4A90E2', + paddingHorizontal: 40, + paddingVertical: 14, + borderRadius: 8, + minWidth: 200, }, buttonText: { - color: '#fff', + color: '#ffffff', fontSize: 16, fontWeight: '600', - marginTop: 8, + textAlign: 'center', }, }); diff --git a/formulus/src/services/ServerConfigService.ts b/formulus/src/services/ServerConfigService.ts new file mode 100644 index 000000000..a4949607f --- /dev/null +++ b/formulus/src/services/ServerConfigService.ts @@ -0,0 +1,120 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const SERVER_URL_KEY = '@server_url'; +const SERVER_URL_STORAGE_KEY = '@settings'; + +export class ServerConfigService { + private static instance: ServerConfigService; + + private constructor() {} + + public static getInstance(): ServerConfigService { + if (!ServerConfigService.instance) { + ServerConfigService.instance = new ServerConfigService(); + } + return ServerConfigService.instance; + } + + async saveServerUrl(serverUrl: string): Promise { + try { + await AsyncStorage.setItem(SERVER_URL_KEY, serverUrl); + await AsyncStorage.setItem( + SERVER_URL_STORAGE_KEY, + JSON.stringify({serverUrl}), + ); + } catch (error) { + console.error('Failed to save server URL:', error); + throw error; + } + } + + async getServerUrl(): Promise { + try { + const url = await AsyncStorage.getItem(SERVER_URL_KEY); + if (url) { + return url; + } + + const settings = await AsyncStorage.getItem(SERVER_URL_STORAGE_KEY); + if (settings) { + const parsed = JSON.parse(settings); + return parsed.serverUrl || null; + } + + return null; + } catch (error) { + console.error('Failed to get server URL:', error); + return null; + } + } + + async clearServerUrl(): Promise { + try { + await AsyncStorage.removeItem(SERVER_URL_KEY); + await AsyncStorage.removeItem(SERVER_URL_STORAGE_KEY); + } catch (error) { + console.error('Failed to clear server URL:', error); + throw error; + } + } + + async testConnection(serverUrl: string): Promise<{success: boolean; message: string}> { + if (!serverUrl.trim()) { + return {success: false, message: 'Please enter a server URL'}; + } + + try { + const url = new URL(serverUrl); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return {success: false, message: 'URL must be HTTP or HTTPS'}; + } + } catch { + return {success: false, message: 'Please enter a valid URL'}; + } + + try { + const healthUrl = `${serverUrl.replace(/\/$/, '')}/health`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(healthUrl, { + method: 'GET', + signal: controller.signal, + headers: { + 'Accept': 'application/json', + }, + }); + + clearTimeout(timeoutId); + + if (response.ok) { + return {success: true, message: 'Connection successful!'}; + } else { + return { + success: false, + message: `Server responded with status ${response.status}`, + }; + } + } catch (error: any) { + if (error.name === 'AbortError') { + return {success: false, message: 'Connection timeout. Check your network and server.'}; + } + + const errorMessage = error.message || 'Unknown error'; + if (errorMessage.includes('Network request failed') || errorMessage.includes('Failed to fetch')) { + return { + success: false, + message: 'Cannot reach server. Check:\n• Server is running\n• Correct IP/URL\n• Same network (for local IP)\n• Firewall settings', + }; + } + + return { + success: false, + message: `Connection failed: ${errorMessage}`, + }; + } + } +} + +export const serverConfigService = ServerConfigService.getInstance(); + diff --git a/formulus/src/services/SyncService.ts b/formulus/src/services/SyncService.ts index fc5523c24..fce251a20 100644 --- a/formulus/src/services/SyncService.ts +++ b/formulus/src/services/SyncService.ts @@ -196,8 +196,14 @@ export class SyncService { this.updateStatus('Starting app bundle sync...'); try { + // Get manifest to know what version we're downloading + const manifest = await synkronusApi.getManifest(); + await this.downloadAppBundle(); + // Save the version after successful download + await AsyncStorage.setItem('@appVersion', manifest.version); + // Invalidate FormService cache to reload new form specs this.updateStatus('Refreshing form specifications...'); const formService = await FormService.getInstance(); @@ -217,10 +223,8 @@ export class SyncService { private async downloadAppBundle(): Promise { try { - // Get the manifest this.updateStatus('Fetching manifest...'); const manifest = await synkronusApi.getManifest(); - console.log('Manifest:', manifest); // Clean out the existing app bundle await synkronusApi.removeAppBundleFiles(); @@ -242,7 +246,6 @@ export class SyncService { ); const results = [...formResults, ...appResults]; - console.debug('Download results:', results); if (results.some(r => !r.success)) { const errorMessages = results @@ -261,13 +264,9 @@ export class SyncService { // Initialize any required state const lastSeenVersion = await AsyncStorage.getItem('@last_seen_version'); - try { - const appVersion = await appVersionService.getVersion(); - await AsyncStorage.setItem('@appVersion', appVersion); - console.log('SyncService: Initialized with app version:', appVersion); - } catch (error) { - console.error('SyncService: Failed to get app version, using fallback:', error); - await AsyncStorage.setItem('@appVersion', '1.0.0'); // Fallback version + const existingAppVersion = await AsyncStorage.getItem('@appVersion'); + if (!existingAppVersion) { + await AsyncStorage.setItem('@appVersion', '0'); } if (lastSeenVersion) { diff --git a/formulus/src/types/NavigationTypes.ts b/formulus/src/types/NavigationTypes.ts index 167cb4e94..5d51fc2d0 100644 --- a/formulus/src/types/NavigationTypes.ts +++ b/formulus/src/types/NavigationTypes.ts @@ -1,8 +1,14 @@ -// Defines the types for all possible routes/screens in your navigation stack -export type RootStackParamList = { - Welcome: undefined; // The Welcome screen takes no parameters - Home: undefined; // The Home screen takes no parameters - Settings: undefined; // The Settings screen takes no parameters - FormManagement: undefined; // The Form Management screen takes no parameters - Sync: undefined; // The Sync screen takes no parameters -}; \ No newline at end of file +export type MainTabParamList = { + Home: undefined; + Forms: undefined; + Observations: undefined; + Sync: undefined; + More: {openDrawer?: number} | undefined; +}; + +export type MainAppStackParamList = { + Welcome: undefined; + MainApp: undefined; + Settings: undefined; + FormManagement: undefined; +};