diff --git a/messages/en.json b/messages/en.json index cc2876899..7cb3f521e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -32,7 +32,7 @@ "message": "Start Tracks" }, "Modal.GPSEnable.button.loading": { - "message": "Loading…" + "message": "Loading..." }, "Modal.GPSEnable.button.stop": { "message": "Stop Tracks" @@ -632,6 +632,57 @@ "description": "Title of settings screen", "message": "Settings" }, + "screens.Sync.CreateOrJoinProjectDisplay.buttonText": { + "message": "Create or Join Project" + }, + "screens.Sync.CreateOrJoinProjectDisplay.description": { + "message": "Create or Join a Project to sync with other devices" + }, + "screens.Sync.HeaderTitle.noWiFi": { + "message": "No WiFi" + }, + "screens.Sync.NoWifiDisplay.buttonText": { + "message": "Open Settings" + }, + "screens.Sync.NoWifiDisplay.description": { + "message": "Open your phone settings and connect to a WiFi network to synchronize" + }, + "screens.Sync.NoWifiDisplay.title": { + "message": "No WiFi" + }, + "screens.Sync.ProjectSyncDisplay.buttonTextDone": { + "message": "You're all caught up" + }, + "screens.Sync.ProjectSyncDisplay.buttonTextStop": { + "message": "Stop" + }, + "screens.Sync.ProjectSyncDisplay.buttonTextSync": { + "message": "Sync" + }, + "screens.Sync.ProjectSyncDisplay.deviceName": { + "message": "Your device name is {name}" + }, + "screens.Sync.ProjectSyncDisplay.devicesNearby": { + "message": "{count} {count, plural, one {device} other {devices}} nearby/connected" + }, + "screens.Sync.ProjectSyncDisplay.devicesSyncing": { + "message": "Syncing with {count} {count, plural, one {Device} other {Devices}}" + }, + "screens.Sync.ProjectSyncDisplay.devicesWaitingToSync": { + "message": "{count} {count, plural, one {Device} other {Devices}} Waiting to Sync with you" + }, + "screens.Sync.ProjectSyncDisplay.noDevicesSyncing": { + "message": "No Devices are Syncing" + }, + "screens.Sync.ProjectSyncDisplay.syncProgress": { + "message": "{value}%" + }, + "screens.Sync.ProjectSyncDisplay.syncing": { + "message": "Syncing…" + }, + "screens.Sync.ProjectSyncDisplay.upToDate": { + "message": "Up to Date! No data to Sync" + }, "sharedComponents.DeviceCard.ThisDevice": { "message": "This Device!" }, diff --git a/package-lock.json b/package-lock.json index 176b62f7a..6ffdaf1ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4370,10 +4370,7 @@ "node_modules/@formatjs/intl-getcanonicallocales": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-2.3.0.tgz", - "integrity": "sha512-BOXbLwqQ7nKua/l7tKqDLRN84WupDXFDhGJQMFvsMVA2dKuOdRaWTxWpL3cJ7qPkoNw11Jf+Xpj4OSPBBvW0eQ==", - "dependencies": { - "tslib": "^2.4.0" - } + "integrity": "sha512-BOXbLwqQ7nKua/l7tKqDLRN84WupDXFDhGJQMFvsMVA2dKuOdRaWTxWpL3cJ7qPkoNw11Jf+Xpj4OSPBBvW0eQ==" }, "node_modules/@formatjs/intl-listformat": { "version": "7.4.0", diff --git a/src/frontend/Navigation/ScreenGroups/AppScreens.tsx b/src/frontend/Navigation/ScreenGroups/AppScreens.tsx index 500788984..93fd8c817 100644 --- a/src/frontend/Navigation/ScreenGroups/AppScreens.tsx +++ b/src/frontend/Navigation/ScreenGroups/AppScreens.tsx @@ -57,6 +57,10 @@ import {CameraTabBarIcon} from './TabBar/CameraTabBarIcon'; import {MapTabBarIcon} from './TabBar/MapTabBarIcon'; import {InviteDeclined} from '../../screens/Settings/ProjectSettings/YourTeam/InviteDeclined'; import {UnableToCancelInvite} from '../../screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/UnableToCancelInvite'; +import { + SyncScreen, + createNavigationOptions as createSyncNavOptions, +} from '../../screens/Sync'; export const TAB_BAR_HEIGHT = 70; @@ -76,7 +80,6 @@ type InviteProps = { export type AppList = { Home: NavigatorScreenParams; GpsModal: undefined; - SyncModal: undefined; Settings: undefined; ProjectConfig: undefined; AboutMapeo: undefined; @@ -136,6 +139,7 @@ export type AppList = { UnableToCancelInvite: InviteProps; DeviceNameDisplay: undefined; DeviceNameEdit: undefined; + Sync: undefined; }; const Tab = createBottomTabNavigator(); @@ -366,5 +370,10 @@ export const createDefaultScreenGroup = ( component={UnableToCancelInvite} options={{headerShown: false}} /> + ); diff --git a/src/frontend/hooks/server/observations.ts b/src/frontend/hooks/server/observations.ts index 11870dcaf..a758d0981 100644 --- a/src/frontend/hooks/server/observations.ts +++ b/src/frontend/hooks/server/observations.ts @@ -8,6 +8,8 @@ import { import {useProject} from './projects'; import {ClientGeneratedObservation} from '../../sharedTypes'; +export const OBSERVATION_KEY = 'observations'; + export function useObservations() { const project = useProject(); @@ -24,7 +26,7 @@ export function useObservation(observationId: string) { const project = useProject(); return useSuspenseQuery({ - queryKey: ['observations', observationId], + queryKey: [OBSERVATION_KEY, observationId], queryFn: async () => { if (!project) throw new Error('Project instance does not exist'); return project.observation.getByDocId(observationId); @@ -46,7 +48,7 @@ export function useCreateObservation() { }); }, onSuccess: () => { - queryClient.invalidateQueries({queryKey: ['observations']}); + queryClient.invalidateQueries({queryKey: [OBSERVATION_KEY]}); }, }); } @@ -67,7 +69,7 @@ export function useEditObservation() { return project.observation.update(id, value); }, onSuccess: () => { - queryClient.invalidateQueries({queryKey: ['observations']}); + queryClient.invalidateQueries({queryKey: [OBSERVATION_KEY]}); }, }); } @@ -82,7 +84,7 @@ export function useDeleteObservation() { return project.observation.delete(id); }, onSuccess: () => { - queryClient.invalidateQueries({queryKey: ['observations']}); + queryClient.invalidateQueries({queryKey: [OBSERVATION_KEY]}); }, }); } diff --git a/src/frontend/hooks/server/projects.ts b/src/frontend/hooks/server/projects.ts index 7f5c39e90..3a7204dc6 100644 --- a/src/frontend/hooks/server/projects.ts +++ b/src/frontend/hooks/server/projects.ts @@ -52,10 +52,11 @@ export function useProjectMembers() { export function useProjectSettings() { const project = useProject(); + return useQuery({ - queryFn: async () => { - return await project.$getProjectSettings(); - }, queryKey: ['projectSettings'], + queryFn: () => { + return project.$getProjectSettings(); + }, }); } diff --git a/src/frontend/hooks/useSyncState.ts b/src/frontend/hooks/useSyncState.ts index 0ebcb540c..92e257c29 100644 --- a/src/frontend/hooks/useSyncState.ts +++ b/src/frontend/hooks/useSyncState.ts @@ -1,16 +1,25 @@ import {MapeoProjectApi} from '@mapeo/ipc'; -import React from 'react'; +import {useCallback, useSyncExternalStore} from 'react'; + import {useProject} from './server/projects'; -type SyncState = Awaited>; +export type SyncState = Awaited< + ReturnType +>; -const projectStateMap = new WeakMap< - MapeoProjectApi, - ReturnType ->(); +const projectSyncStoreMap = new WeakMap(); -function identity(state: SyncState | undefined) { - return state; +function useSyncStore() { + const project = useProject(); + + let syncStore = projectSyncStoreMap.get(project); + + if (!syncStore) { + syncStore = new SyncStore(project); + projectSyncStoreMap.set(project, syncStore); + } + + return syncStore; } /** @@ -29,68 +38,131 @@ function identity(state: SyncState | undefined) { * @param selector Select a subset of the state to subscribe to. Defaults to return the entire state. * @returns */ -export function useSyncState( - selector: (state: SyncState | undefined) => S = identity as any, +export function useSyncState( + selector: (state: SyncState | null) => S = identity as any, ): S { - const project = useProject(); + const syncStore = useSyncStore(); - let state = projectStateMap.get(project); - if (!state) { - state = createSyncState(project); - projectStateMap.set(project, state); - } - - const {subscribe, getSnapshot} = state; + const {subscribe, getStateSnapshot} = syncStore; - const getSelectorSnapshot = React.useCallback( - () => selector(getSnapshot()), - [selector, getSnapshot], + const getSelectorSnapshot = useCallback( + () => selector(getStateSnapshot()), + [selector, getStateSnapshot], ); - return React.useSyncExternalStore(subscribe, getSelectorSnapshot); + return useSyncExternalStore(subscribe, getSelectorSnapshot); } -function createSyncState(project: MapeoProjectApi) { - let state: SyncState | undefined; - let isSubscribedInternal = false; - const listeners = new Set<() => void>(); - let error: Error | undefined; +/** + * Calculates progress of *data* sync based on sync state. + * + * @returns A number between 0 and 1 when data sync is enabled. `null` otherwise. + */ +export function useSyncProgress() { + const {subscribe, getProgressSnapshot} = useSyncStore(); + return useSyncExternalStore(subscribe, getProgressSnapshot); +} + +class SyncStore { + #project: MapeoProjectApi; + + #listeners = new Set<() => void>(); + #isSubscribedInternal = false; + #error: Error | null = null; + #state: SyncState | null = null; + + /** + * Represents maximum value of `#state.data.want + #state.data.wanted` while data syncing is enabled. + * Resets to null when data syncing goes from enabled to disabled. + */ + #maxDataSyncCount: number | null = null; + + constructor(project: MapeoProjectApi) { + this.#project = project; + } + + subscribe = (listener: () => void) => { + this.#listeners.add(listener); + if (!this.#isSubscribedInternal) this.#startSubscription(); + return () => { + this.#listeners.delete(listener); + if (this.#listeners.size === 0) this.#stopSubscription(); + }; + }; + + getStateSnapshot = () => { + if (this.#error) throw this.#error; + return this.#state; + }; + + getProgressSnapshot = () => { + if (this.#maxDataSyncCount === null || this.#state === null) { + return null; + } + + if (this.#maxDataSyncCount === 0) { + return 1; + } + + const currentCount = this.#state.data.want + this.#state.data.wanted; + + const ratio = + (this.#maxDataSyncCount - currentCount) / this.#maxDataSyncCount; + + if (ratio <= 0) return 0; + if (ratio >= 1) return 1; - function onSyncState(newState: SyncState) { - state = newState; - error = undefined; - listeners.forEach(listener => listener()); + return clamp(ratio, 0.01, 0.99); + }; + + #notifyListeners() { + for (const listener of this.#listeners) { + listener(); + } } - function subscribeInternal() { - project.$sync.on('sync-state', onSyncState); - isSubscribedInternal = true; - project.$sync + #onSyncState = (state: SyncState) => { + // Indicates whether data syncing went from enabled to disabled + const isDataSyncStopped = this.#state?.data.syncing && !state.data.syncing; + + if (isDataSyncStopped) { + this.#maxDataSyncCount = null; + } else { + const newSyncCount = state.data.want + state.data.wanted; + + this.#maxDataSyncCount = + this.#maxDataSyncCount === null + ? newSyncCount + : Math.max(this.#maxDataSyncCount, newSyncCount); + } + + this.#state = state; + this.#error = null; + this.#notifyListeners(); + }; + + #startSubscription = () => { + this.#project.$sync.on('sync-state', this.#onSyncState); + this.#isSubscribedInternal = true; + this.#project.$sync .getState() - .then(onSyncState) + .then(this.#onSyncState) .catch(e => { - error = e; - listeners.forEach(listener => listener()); + this.#error = e; + this.#notifyListeners(); }); - } - - function unsubscribeInternal() { - isSubscribedInternal = false; - project.$sync.off('sync-state', onSyncState); - } + }; - return { - subscribe: (listener: () => void) => { - listeners.add(listener); - if (!isSubscribedInternal) subscribeInternal(); - return () => { - listeners.delete(listener); - if (listeners.size === 0) unsubscribeInternal(); - }; - }, - getSnapshot: () => { - if (error) throw error; - return state; - }, + #stopSubscription = () => { + this.#isSubscribedInternal = false; + this.#project.$sync.off('sync-state', this.#onSyncState); }; } + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(value, max)); +} + +function identity(state: SyncState | undefined) { + return state; +} diff --git a/src/frontend/images/ObservationsProject.svg b/src/frontend/images/ObservationsProject.svg new file mode 100644 index 000000000..d382244ea --- /dev/null +++ b/src/frontend/images/ObservationsProject.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/frontend/lib/linking.ts b/src/frontend/lib/linking.ts new file mode 100644 index 000000000..1a1786d60 --- /dev/null +++ b/src/frontend/lib/linking.ts @@ -0,0 +1,12 @@ +import {Platform, Linking} from 'react-native'; + +export async function openWiFiSettings() { + if (Platform.OS !== 'android') + throw new Error( + 'openWiFiSettings() is currently only available on Android', + ); + + // https://github.com/facebook/react-native/blob/v0.73.5/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.java#L204 + // https://developer.android.com/reference/android/provider/Settings#ACTION_WIFI_SETTINGS + return Linking.sendIntent('android.settings.WIFI_SETTINGS'); +} diff --git a/src/frontend/screens/Sync/CreateOrJoinProjectDisplay.tsx b/src/frontend/screens/Sync/CreateOrJoinProjectDisplay.tsx new file mode 100644 index 000000000..e17ccceea --- /dev/null +++ b/src/frontend/screens/Sync/CreateOrJoinProjectDisplay.tsx @@ -0,0 +1,52 @@ +import {StyleSheet, View} from 'react-native'; +import {defineMessages, useIntl} from 'react-intl'; + +import {Button} from '../../sharedComponents/Button'; +import {Text} from '../../sharedComponents/Text'; +import {ScreenContentWithDock} from '../../sharedComponents/ScreenContentWithDock'; +import {WHITE} from '../../lib/styles'; + +const m = defineMessages({ + description: { + id: 'screens.Sync.CreateOrJoinProjectDisplay.description', + defaultMessage: 'Create or Join a Project to sync with other devices', + }, + buttonText: { + id: 'screens.Sync.CreateOrJoinProjectDisplay.buttonText', + defaultMessage: 'Create or Join Project', + }, +}); + +export const CreateOrJoinProjectDisplay = ({ + onCreateOrJoinProject, +}: { + onCreateOrJoinProject: () => void; +}) => { + const {formatMessage: t} = useIntl(); + + return ( + + {t(m.buttonText)} + + }> + + {t(m.description)} + + + ); +}; + +const styles = StyleSheet.create({ + contentContainer: {paddingTop: 40}, + descriptionText: { + textAlign: 'center', + fontSize: 40, + }, + buttonText: { + color: WHITE, + fontWeight: 'bold', + fontSize: 20, + }, +}); diff --git a/src/frontend/screens/Sync/HeaderTitle.tsx b/src/frontend/screens/Sync/HeaderTitle.tsx new file mode 100644 index 000000000..b7425c4bf --- /dev/null +++ b/src/frontend/screens/Sync/HeaderTitle.tsx @@ -0,0 +1,60 @@ +import {StyleSheet, View} from 'react-native'; +import {useDimensions} from '@react-native-community/hooks'; +import {defineMessages, useIntl} from 'react-intl'; + +import {useLocalDiscoveryState} from '../../hooks/useLocalDiscoveryState'; +import {Circle} from '../../sharedComponents/icons/Circle'; +import {WifiIcon, WifiOffIcon} from '../../sharedComponents/icons'; +import {Text} from '../../sharedComponents/Text'; +import {COMAPEO_DARK_BLUE, WHITE} from '../../lib/styles'; + +const m = defineMessages({ + noWiFi: { + id: 'screens.Sync.HeaderTitle.noWiFi', + defaultMessage: 'No WiFi', + }, +}); + +export function HeaderTitle() { + const {formatMessage: t} = useIntl(); + const screenWidth = useDimensions().screen.width; + const ssid = useLocalDiscoveryState(state => state.ssid); + + const WifiIconComponent = ssid ? WifiIcon : WifiOffIcon; + + return ( + + + + + + {ssid || t(m.noWiFi)} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + columnGap: 10, + }, + signalIndicator: { + elevation: 0, + backgroundColor: COMAPEO_DARK_BLUE, + }, + wifiText: { + fontWeight: 'bold', + }, +}); diff --git a/src/frontend/screens/Sync/NoWifiDisplay.tsx b/src/frontend/screens/Sync/NoWifiDisplay.tsx new file mode 100644 index 000000000..246d07363 --- /dev/null +++ b/src/frontend/screens/Sync/NoWifiDisplay.tsx @@ -0,0 +1,66 @@ +import {StyleSheet, View} from 'react-native'; +import {defineMessages, useIntl} from 'react-intl'; + +import {WHITE} from '../../lib/styles'; +import {Text} from '../../sharedComponents/Text'; +import {Button} from '../../sharedComponents/Button'; +import {ScreenContentWithDock} from '../../sharedComponents/ScreenContentWithDock'; + +const m = defineMessages({ + title: { + id: 'screens.Sync.NoWifiDisplay.title', + defaultMessage: 'No WiFi', + }, + description: { + id: 'screens.Sync.NoWifiDisplay.description', + defaultMessage: + 'Open your phone settings and connect to a WiFi network to synchronize', + }, + buttonText: { + id: 'screens.Sync.NoWifiDisplay.buttonText', + defaultMessage: 'Open Settings', + }, +}); + +export const NoWifiDisplay = ({ + onOpenSettings, +}: { + onOpenSettings: () => void; +}) => { + const {formatMessage: t} = useIntl(); + + return ( + + {t(m.buttonText)} + + }> + + {t(m.title)} + {t(m.description)} + + + ); +}; + +const styles = StyleSheet.create({ + contentContainer: { + paddingTop: 40, + rowGap: 12, + }, + titleText: { + fontSize: 40, + fontWeight: 'bold', + textAlign: 'center', + }, + descriptionText: { + fontSize: 24, + textAlign: 'center', + }, + buttonText: { + color: WHITE, + fontWeight: 'bold', + fontSize: 20, + }, +}); diff --git a/src/frontend/screens/Sync/ProjectSyncDisplay.tsx b/src/frontend/screens/Sync/ProjectSyncDisplay.tsx new file mode 100644 index 000000000..770924676 --- /dev/null +++ b/src/frontend/screens/Sync/ProjectSyncDisplay.tsx @@ -0,0 +1,274 @@ +import * as React from 'react'; +import {defineMessages, useIntl} from 'react-intl'; +import {StyleSheet, View} from 'react-native'; +import {Bar as ProgressBar} from 'react-native-progress'; +import {useProject} from '../../hooks/server/projects'; +import {SyncState, useSyncProgress} from '../../hooks/useSyncState'; +import ObservationsProjectImage from '../../images/ObservationsProject.svg'; +import { + BLACK, + COMAPEO_BLUE, + DARK_GREY, + LIGHT_GREY, + MEDIUM_GREY, + WHITE, +} from '../../lib/styles'; +import {ScreenContentWithDock} from '../../sharedComponents/ScreenContentWithDock'; +import {Button} from '../../sharedComponents/Button'; +import {Text} from '../../sharedComponents/Text'; +import {StopIcon, SyncIcon, WifiIcon} from '../../sharedComponents/icons'; +import {useQueryClient} from '@tanstack/react-query'; +import {OBSERVATION_KEY} from '../../hooks/server/observations'; +import {useNavigationFromRoot} from '../../hooks/useNavigationWithTypes'; + +const m = defineMessages({ + deviceName: { + id: 'screens.Sync.ProjectSyncDisplay.deviceName', + defaultMessage: 'Your device name is {name}', + }, + devicesNearby: { + id: 'screens.Sync.ProjectSyncDisplay.devicesNearby', + defaultMessage: + '{count} {count, plural, one {device} other {devices}} nearby/connected', + }, + buttonTextSync: { + id: 'screens.Sync.ProjectSyncDisplay.buttonTextSync', + defaultMessage: 'Sync', + }, + buttonTextStop: { + id: 'screens.Sync.ProjectSyncDisplay.buttonTextStop', + defaultMessage: 'Stop', + }, + buttonTextDone: { + id: 'screens.Sync.ProjectSyncDisplay.buttonTextDone', + defaultMessage: "You're all caught up", + }, + noDevicesSyncing: { + id: 'screens.Sync.ProjectSyncDisplay.noDevicesSyncing', + defaultMessage: 'No Devices are Syncing', + }, + devicesWaitingToSync: { + id: 'screens.Sync.ProjectSyncDisplay.devicesWaitingToSync', + defaultMessage: + '{count} {count, plural, one {Device} other {Devices}} Waiting to Sync with you', + }, + devicesSyncing: { + id: 'screens.Sync.ProjectSyncDisplay.devicesSyncing', + defaultMessage: + 'Syncing with {count} {count, plural, one {Device} other {Devices}}', + }, + upToDate: { + id: 'screens.Sync.ProjectSyncDisplay.upToDate', + defaultMessage: 'Up to Date!\nNo data to Sync', + }, + syncing: { + id: 'screens.Sync.ProjectSyncDisplay.syncing', + defaultMessage: 'Syncing…', + }, + syncProgress: { + id: 'screens.Sync.ProjectSyncDisplay.syncProgress', + defaultMessage: '{value}%', + }, +}); + +export const ProjectSyncDisplay = ({ + syncState, + projectName, + deviceName, +}: { + syncState: SyncState; + projectName: string; + deviceName: string; +}) => { + const {formatMessage: t} = useIntl(); + + const project = useProject(); + const queryClient = useQueryClient(); + const navigation = useNavigationFromRoot(); + const {connectedPeers, data, initial} = syncState; + const isSyncDone = !initial.dataToSync && !data.dataToSync; + + // stops sync when user leaves sync screen. The api allows us to continue syncing even if the user is not on the sync screen, but for simplicity we are only allowing sync while on the sync screen. In the future we can easily enable background sync, there are just some UI questions that need to answered before we do that. + React.useEffect(() => { + const unsubscribe = navigation.addListener('beforeRemove', () => { + project.$sync.stop(); + queryClient.invalidateQueries({queryKey: [OBSERVATION_KEY]}); + }); + + return unsubscribe; + }, [navigation, project, queryClient]); + + const isDataSyncEnabled = data.syncing; + + const devicesSyncingText = isSyncDone + ? t(m.upToDate) + : !isDataSyncEnabled && connectedPeers === 0 + ? t(m.noDevicesSyncing) + : t( + !isDataSyncEnabled && connectedPeers > 0 + ? m.devicesWaitingToSync + : m.devicesSyncing, + {count: connectedPeers}, + ); + + return ( + { + project.$sync.stop(); + }}> + + + + {t(m.buttonTextStop)} + + + + ) : ( + + ) + }> + + + {projectName && {projectName}} + {deviceName && ( + + {t(m.deviceName, {name: deviceName})} + + )} + + + {t(m.devicesNearby, {count: connectedPeers})} + + + {devicesSyncingText} + {!isSyncDone && isDataSyncEnabled && } + + ); +}; + +function SyncProgress() { + const {formatMessage: t} = useIntl(); + + const progress = useSyncProgress(); + + const dynamicProgressBarProps = + progress === null + ? {indeterminate: true, indeterminateAnimationDuration: 2000} + : { + progress, + indeterminate: false, + }; + + return ( + + + + {t(m.syncing)} + + + + {progress !== null && ( + + {t(m.syncProgress, {value: Math.round(progress * 100)})} + + )} + + ); +} + +const styles = StyleSheet.create({ + contentContainer: { + gap: 36, + }, + projectInfoContainer: { + alignItems: 'center', + gap: 8, + }, + connectedDevicesInfo: { + flexDirection: 'row', + gap: 8, + }, + projectName: { + fontSize: 24, + fontWeight: 'bold', + }, + deviceName: { + color: MEDIUM_GREY, + }, + syncInfo: { + gap: 20, + }, + titleText: { + fontSize: 40, + textAlign: 'center', + }, + descriptionText: { + fontSize: 24, + textAlign: 'center', + }, + buttonContentContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + buttonTextPrimary: { + fontWeight: 'bold', + fontSize: 20, + color: WHITE, + }, + buttonTextSecondary: { + fontWeight: 'bold', + fontSize: 20, + }, + syncProgressContainer: { + gap: 12, + }, + syncProgressTextContainer: { + flexDirection: 'row', + gap: 8, + alignItems: 'center', + }, + syncProgressTitleText: { + fontSize: 20, + color: COMAPEO_BLUE, + }, + syncProgressText: { + color: MEDIUM_GREY, + alignSelf: 'flex-end', + }, +}); diff --git a/src/frontend/screens/Sync/index.tsx b/src/frontend/screens/Sync/index.tsx new file mode 100644 index 000000000..870d537ce --- /dev/null +++ b/src/frontend/screens/Sync/index.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import {NativeStackNavigationOptions} from '@react-navigation/native-stack'; + +import {NativeRootNavigationProps} from '../../sharedTypes'; +import {IconButton} from '../../sharedComponents/IconButton'; +import {SettingsIcon} from '../../sharedComponents/icons'; +import {useAllProjects, useProjectSettings} from '../../hooks/server/projects'; +import {useLocalDiscoveryState} from '../../hooks/useLocalDiscoveryState'; +import {CreateOrJoinProjectDisplay} from './CreateOrJoinProjectDisplay'; +import {HeaderTitle} from './HeaderTitle'; +import {NoWifiDisplay} from './NoWifiDisplay'; +import {openWiFiSettings} from '../../lib/linking'; +import {ProjectSyncDisplay} from './ProjectSyncDisplay'; +import {Loading} from '../../sharedComponents/Loading'; +import {useSyncState} from '../../hooks/useSyncState'; +import {useDeviceInfo} from '../../hooks/server/deviceInfo'; + +export function createNavigationOptions() { + return ({ + navigation, + }: NativeRootNavigationProps<'Sync'>): NativeStackNavigationOptions => { + return { + headerTitleAlign: 'center', + headerTitle: () => , + headerRight: () => ( + { + navigation.navigate('ProjectSettings'); + }}> + + + ), + }; + }; +} + +export const SyncScreen = ({navigation}: NativeRootNavigationProps<'Sync'>) => { + const wifiStatus = useLocalDiscoveryState(state => state.wifiStatus); + + // TODO: Handle error case + const {isLoading, data} = useAllProjects(); + const syncState = useSyncState(); + const deviceInfoQuery = useDeviceInfo(); + const projectSettingsQuery = useProjectSettings(); + + if (wifiStatus === 'off') { + return ( + { + openWiFiSettings().catch(err => { + // Should not throw but in case it does, no-op + console.warn(err); + }); + }} + /> + ); + } + + // TODO: Replace with proper check of being a part of a shared project + if (data && data.length === 1) { + return ( + navigation.navigate('CreateOrJoinProject')} + /> + ); + } + + if ( + isLoading || + !syncState || + !projectSettingsQuery.data || + !deviceInfoQuery.data + ) { + return ; + } + + return ( + + ); +}; diff --git a/src/frontend/sharedComponents/HomeHeader.tsx b/src/frontend/sharedComponents/HomeHeader.tsx index b7b235a5d..284b4e81b 100644 --- a/src/frontend/sharedComponents/HomeHeader.tsx +++ b/src/frontend/sharedComponents/HomeHeader.tsx @@ -2,8 +2,8 @@ import React, {FC} from 'react'; import {View, StyleSheet} from 'react-native'; import LinearGradient from 'react-native-linear-gradient'; import {IconButton} from './IconButton'; -import {ObservationListIcon} from './icons'; -import {GPSPill} from './GPSPill'; +import {ObservationListIcon, SyncIconCircle} from './icons'; +import {GPSPill} from './GpsPill'; import {BottomTabHeaderProps} from '@react-navigation/bottom-tabs'; export const HomeHeader: FC = ({navigation}) => { @@ -13,7 +13,13 @@ export const HomeHeader: FC = ({navigation}) => { style={styles.linearGradient} colors={['#0006', '#0000']} /> - {/* Placeholder for left button */} + { + navigation.navigate('Sync'); + }}> + + navigation.navigate('ObservationList')} diff --git a/src/frontend/sharedComponents/ScreenContentWithDock.tsx b/src/frontend/sharedComponents/ScreenContentWithDock.tsx new file mode 100644 index 000000000..fe6c766f7 --- /dev/null +++ b/src/frontend/sharedComponents/ScreenContentWithDock.tsx @@ -0,0 +1,43 @@ +import {ReactNode, type PropsWithChildren} from 'react'; +import {ScrollView, StyleSheet, View} from 'react-native'; + +import {ViewStyleProp} from '../sharedTypes'; + +export const ScreenContentWithDock = ({ + children, + contentContainerStyle, + dockContainerStyle, + dockContent, +}: PropsWithChildren<{ + dockContent: ReactNode; + dockContainerStyle?: ViewStyleProp; + contentContainerStyle?: ViewStyleProp; +}>) => { + return ( + + + {children} + + + {dockContent} + + + ); +}; + +const styles = StyleSheet.create({ + container: {flex: 1}, + scrollViewRoot: {flex: 1}, + scrollViewContentContainer: { + padding: 20, + }, + dockedContentContainer: { + flex: 0, + padding: 20, + }, +}); diff --git a/src/frontend/sharedComponents/icons/index.tsx b/src/frontend/sharedComponents/icons/index.tsx index 59338bd6d..636955b85 100644 --- a/src/frontend/sharedComponents/icons/index.tsx +++ b/src/frontend/sharedComponents/icons/index.tsx @@ -5,7 +5,7 @@ import FontAwesomeIcon from 'react-native-vector-icons/FontAwesome'; import {Image} from 'react-native'; import {Circle} from './Circle'; -import {RED, DARK_GREY, MANGO, MEDIUM_GREY} from '../../lib/styles'; +import {RED, DARK_GREY, MANGO, MEDIUM_GREY, WHITE} from '../../lib/styles'; import type {TextStyleProp, ImageStyleProp} from '../../sharedTypes'; type FontIconProps = { @@ -136,15 +136,11 @@ export const WifiIcon = ({ }: FontIconProps) => ( ); -export const SyncIcon = ({ - size = 30, - color = 'white', - style, -}: FontIconProps) => ( +export const SyncIcon = ({size = 20, color = WHITE}: FontIconProps) => ( ); @@ -198,3 +194,7 @@ export const LocationFollowingIcon = ({ ); + +export const StopIcon = ({color = WHITE, size = 30}: FontIconProps) => ( + +);