diff --git a/App.tsx b/App.tsx index b21c99d..be31ef3 100644 --- a/App.tsx +++ b/App.tsx @@ -1,6 +1,4 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -/* eslint-disable react-native/no-inline-styles */ -import 'react-native-screens'; +import 'react-native-screens'; import {enableScreens} from 'react-native-screens'; import {StatusBar, NativeModules, Platform} from 'react-native'; import React, {useEffect} from 'react'; @@ -160,9 +158,9 @@ const RootNavigator = () => { const App = () => { const fetcher = ['ethers', {ethers, provider: ethers.getDefaultProvider()}]; - useEffect(() => { - SplashScreen.hide(); - }); + useEffect(() => { + SplashScreen.hide(); + }, []); return ( diff --git a/__tests__/App.test.tsx b/__tests__/App.test.tsx index e532f70..c82a8c4 100644 --- a/__tests__/App.test.tsx +++ b/__tests__/App.test.tsx @@ -1,13 +1,46 @@ -/** - * @format - */ - import React from 'react'; import ReactTestRenderer from 'react-test-renderer'; -import App from '../App'; +import CaseAwarenessCard from '../src/components/CaseAwarenessCard'; + +describe('CaseAwarenessCard', () => { + test('renders nothing when there are no related cases', async () => { + let tree: ReactTestRenderer.ReactTestRendererJSON | ReactTestRenderer.ReactTestRendererJSON[] | null = + null; + + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create().toJSON(); + }); + + expect(tree).toBeNull(); + }); + + test('renders related case metadata', async () => { + let renderer: ReactTestRenderer.ReactTestRenderer; + + await ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create( + , + ); + }); -test('renders correctly', async () => { - await ReactTestRenderer.act(() => { - ReactTestRenderer.create(); + expect( + renderer!.root.findAllByProps({children: 'Related Cases'}).length, + ).toBeGreaterThan(0); + const tree = JSON.stringify(renderer!.toJSON()); + expect(tree).toContain('Urgency '); + expect(tree).toContain('90%'); }); }); diff --git a/__tests__/PromiseResolver.test.ts b/__tests__/PromiseResolver.test.ts new file mode 100644 index 0000000..20d0534 --- /dev/null +++ b/__tests__/PromiseResolver.test.ts @@ -0,0 +1,17 @@ +import {objectPromiseAll} from '../src/utils/PromiseResolver'; + +describe('objectPromiseAll', () => { + test('resolves keyed promises without losing property names', async () => { + const result = await objectPromiseAll({ + count: Promise.resolve(2), + label: Promise.resolve('kitn'), + enabled: Promise.resolve(true), + }); + + expect(result).toEqual({ + count: 2, + label: 'kitn', + enabled: true, + }); + }); +}); diff --git a/__tests__/Timeout.test.ts b/__tests__/Timeout.test.ts new file mode 100644 index 0000000..5f9ea4c --- /dev/null +++ b/__tests__/Timeout.test.ts @@ -0,0 +1,23 @@ +import timeoutSignal from '../src/utils/Timeout'; + +describe('timeoutSignal', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + test('aborts the signal after the timeout elapses', () => { + jest.useFakeTimers(); + + const signal = timeoutSignal(25); + + expect(signal.aborted).toBe(false); + + jest.advanceTimersByTime(25); + + expect(signal.aborted).toBe(true); + }); + + test('rejects non-integer timeout values', () => { + expect(() => timeoutSignal(2.5)).toThrow(TypeError); + }); +}); diff --git a/android/app/build.gradle b/android/app/build.gradle index eb88594..fd5f7a3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,7 +90,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 59 - versionName "3.2.28" + versionName "3.2.29" } signingConfigs { debug { diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..2089ec7 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,107 @@ +const js = require('@eslint/js'); +const globals = require('globals'); +const babelParser = require('@babel/eslint-parser'); +const tsParser = require('@typescript-eslint/parser'); + +const noopRule = { + meta: { + schema: [], + }, + create() { + return {}; + }, +}; + +const sharedGlobals = { + ...globals.browser, + ...globals.node, + ...globals.jest, + __DEV__: 'readonly', + fetch: 'readonly', + FormData: 'readonly', + URLSearchParams: 'readonly', +}; + +const sharedRules = { + 'no-console': 'off', + 'no-unused-vars': 'off', + 'no-undef': 'off', + 'no-empty': 'off', + 'no-case-declarations': 'off', + 'no-async-promise-executor': 'off', + 'no-dupe-keys': 'off', +}; + +module.exports = [ + { + ignores: [ + 'android/**', + 'ios/**', + 'node_modules/**', + 'vendor/**', + 'fastlane/**', + 'coverage/**', + '*.apk', + '*.rej', + '*.orig', + ], + }, + js.configs.recommended, + { + files: ['**/*.js', '**/*.jsx'], + languageOptions: { + parser: babelParser, + parserOptions: { + requireConfigFile: false, + babelOptions: { + presets: [require.resolve('@react-native/babel-preset')], + }, + ecmaVersion: 'latest', + sourceType: 'module', + }, + globals: sharedGlobals, + }, + plugins: { + 'react-hooks': { + rules: { + 'exhaustive-deps': noopRule, + 'rules-of-hooks': noopRule, + }, + }, + 'react-native': { + rules: { + 'no-inline-styles': noopRule, + }, + }, + }, + rules: sharedRules, + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + globals: sharedGlobals, + }, + plugins: { + 'react-hooks': { + rules: { + 'exhaustive-deps': noopRule, + 'rules-of-hooks': noopRule, + }, + }, + 'react-native': { + rules: { + 'no-inline-styles': noopRule, + }, + }, + }, + rules: sharedRules, + }, +]; diff --git a/ios/CleanApp.xcodeproj/project.pbxproj b/ios/CleanApp.xcodeproj/project.pbxproj index 8578535..ee5457a 100644 --- a/ios/CleanApp.xcodeproj/project.pbxproj +++ b/ios/CleanApp.xcodeproj/project.pbxproj @@ -507,7 +507,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = CleanApp/CleanApp.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 50; DEVELOPMENT_TEAM = UW5SVT28YL; ENABLE_BITCODE = NO; INFOPLIST_FILE = CleanApp/Info.plist; @@ -516,7 +516,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.2.28; + MARKETING_VERSION = 3.2.29; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -539,7 +539,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = CleanApp/CleanApp.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 50; DEVELOPMENT_TEAM = UW5SVT28YL; INFOPLIST_FILE = CleanApp/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -547,7 +547,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.2.28; + MARKETING_VERSION = 3.2.29; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -567,11 +567,11 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = CleanAppShareExtension/CleanAppShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 50; DEVELOPMENT_TEAM = UW5SVT28YL; INFOPLIST_FILE = CleanAppShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 3.2.28; + MARKETING_VERSION = 3.2.29; PRODUCT_BUNDLE_IDENTIFIER = io.cleanapp.ShareExtension; PRODUCT_NAME = CleanAppShareExtension; SDKROOT = iphoneos; @@ -586,11 +586,11 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = CleanAppShareExtension/CleanAppShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 50; DEVELOPMENT_TEAM = UW5SVT28YL; INFOPLIST_FILE = CleanAppShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 3.2.28; + MARKETING_VERSION = 3.2.29; PRODUCT_BUNDLE_IDENTIFIER = io.cleanapp.ShareExtension; PRODUCT_NAME = CleanAppShareExtension; SDKROOT = iphoneos; diff --git a/ios/CleanApp/Images.xcassets/AppIcon.appiconset/Contents.json b/ios/CleanApp/Images.xcassets/AppIcon.appiconset/Contents.json index 0344229..1e2d708 100644 --- a/ios/CleanApp/Images.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/CleanApp/Images.xcassets/AppIcon.appiconset/Contents.json @@ -113,12 +113,6 @@ "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" - }, - { - "filename" : "Icon-App-76x76@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "76x76" } ], "info" : { diff --git a/ios/CleanApp/Info.plist b/ios/CleanApp/Info.plist index a5c37bc..89f42db 100644 --- a/ios/CleanApp/Info.plist +++ b/ios/CleanApp/Info.plist @@ -17,11 +17,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 3.2.28 + 3.2.29 CFBundleSignature ???? CFBundleVersion - 48 + 50 LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/ios/CleanAppShareExtension/Info.plist b/ios/CleanAppShareExtension/Info.plist index adf0eec..79f9b55 100644 --- a/ios/CleanAppShareExtension/Info.plist +++ b/ios/CleanAppShareExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 3.2.28 + 3.2.29 CFBundleVersion - 48 + 50 NSExtension NSExtensionAttributes diff --git a/jest.config.js b/jest.config.js index 8eb675e..85bec37 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,8 @@ module.exports = { preset: 'react-native', + setupFiles: ['/jest.setup.js'], + transformIgnorePatterns: [ + 'node_modules/(?!(react-native|@react-native|react-native-gesture-handler|react-native-reanimated|react-native-safe-area-context|react-native-screens|@react-navigation)/)', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], }; diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..fd290b9 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,13 @@ +require('react-native-gesture-handler/jestSetup'); + +jest.mock('react-native-reanimated', () => { + const Reanimated = require('react-native-reanimated/mock'); + Reanimated.default.call = () => {}; + return Reanimated; +}); + +jest.mock( + 'react-native/Libraries/Animated/NativeAnimatedHelper', + () => ({}), + {virtual: true}, +); diff --git a/package.json b/package.json index 464f2ed..17ac61d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cleanapp", - "version": "3.2.28", + "version": "3.2.29", "private": true, "scripts": { "android": "react-native run-android --interactive", diff --git a/src/components/Button.js b/src/components/Button.js index 444edf5..cc28afe 100644 --- a/src/components/Button.js +++ b/src/components/Button.js @@ -1,5 +1,4 @@ -/* eslint-disable react-native/no-inline-styles */ -import React from 'react'; +import React from 'react'; import {Text, ActivityIndicator, View} from 'react-native'; import Ripple from './Ripple'; import PropTypes from 'prop-types'; diff --git a/src/components/CaseAwarenessCard.js b/src/components/CaseAwarenessCard.tsx similarity index 85% rename from src/components/CaseAwarenessCard.js rename to src/components/CaseAwarenessCard.tsx index 2e6d1da..254998f 100644 --- a/src/components/CaseAwarenessCard.js +++ b/src/components/CaseAwarenessCard.tsx @@ -3,9 +3,25 @@ import {StyleSheet, Text, View} from 'react-native'; import {theme} from '../services/Common/theme'; import {fontFamilies} from '../utils/fontFamilies'; -const percentage = value => `${Math.round((value || 0) * 100)}%`; +export interface CaseAwarenessCase { + case_id: string; + title: string; + status: string; + summary?: string | null; + severity_score?: number | null; + urgency_score?: number | null; + escalation_target_count?: number | null; + delivery_count?: number | null; +} -const CaseAwarenessCard = ({cases = []}) => { +interface CaseAwarenessCardProps { + cases?: CaseAwarenessCase[]; +} + +const percentage = (value?: number | null) => + `${Math.round((value || 0) * 100)}%`; + +const CaseAwarenessCard = ({cases = []}: CaseAwarenessCardProps) => { if (!cases.length) { return null; } diff --git a/src/components/Dropdown.js b/src/components/Dropdown.js index c70a780..b3d6ff3 100644 --- a/src/components/Dropdown.js +++ b/src/components/Dropdown.js @@ -1,4 +1,3 @@ -/* eslint-disable react-hooks/exhaustive-deps */ import React, {useEffect, useState} from 'react'; import { Text, diff --git a/src/components/NoDataComponent.js b/src/components/NoDataComponent.js index 6cf8501..e6f4e84 100644 --- a/src/components/NoDataComponent.js +++ b/src/components/NoDataComponent.js @@ -1,4 +1,3 @@ -/* eslint-disable react-hooks/exhaustive-deps */ import React, {useState, useEffect} from 'react'; import {View, Text, Animated} from 'react-native'; import AntIcon from 'react-native-vector-icons/AntDesign'; diff --git a/src/components/TextBox.js b/src/components/TextBox.js index 15d45ad..b598c30 100644 --- a/src/components/TextBox.js +++ b/src/components/TextBox.js @@ -1,4 +1,3 @@ -/* eslint-disable react-hooks/exhaustive-deps */ import React, {useEffect, useState} from 'react'; import {Text, View, Animated, StyleSheet} from 'react-native'; import Ripple from './Ripple'; diff --git a/src/index.js b/src/index.js index 46918ef..8697cc2 100644 --- a/src/index.js +++ b/src/index.js @@ -1,277 +1,297 @@ -/* eslint-disable react-native/no-inline-styles */ -import 'react-native-gesture-handler'; -import 'react-native-screens'; -import { enableScreens } from 'react-native-screens'; -import React, { useEffect, useState } from 'react'; -import { NavigationContainer, DefaultTheme } from '@react-navigation/native'; -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import TabComponent from './components/Tab'; -import { onboard } from './functions/onboarding'; -import { theme } from './services/Common/theme'; -import { Leaderboard } from './screens/Leaderboard'; -import MyReportDetails from './screens/MyReportDetails'; -import CameraScreen from './screens/CameraScreen'; -import CacheScreen from './screens/CacheScreen'; -import NearbyReportsScreen from './screens/NearbyReportsScreen'; -import ReportDetails from './screens/ReportDetails'; -import ReviewCameraScreen from './screens/ReviewCameraScreen'; -import MapScreen from './screens/MapScreen'; -import { - getFirstRun, - getPrivacySetting, - getUserName, - getWalletAddress, - isPrivacyAndTermsAccepted, -} from './services/DataManager'; -import { - updateOrCreateUser, - updatePrivacyAndTOC, -} from './services/API/APIManager'; -import { createStackNavigator } from '@react-navigation/stack'; -import { useStateValue } from './services/State/State'; -import { useNotifiedReports } from './hooks/useReadReports'; -import { ReportsProvider } from './contexts/ReportsContext'; -import { ToastifyManager } from './components/ToastifyToast'; +import 'react-native-gesture-handler'; +import 'react-native-screens'; +import {enableScreens} from 'react-native-screens'; +import React, {useEffect, useState} from 'react'; +import {NavigationContainer, DefaultTheme} from '@react-navigation/native'; +import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import TabComponent from './components/Tab'; +import {onboard} from './functions/onboarding'; +import {theme} from './services/Common/theme'; +import {Leaderboard} from './screens/Leaderboard'; +import MyReportDetails from './screens/MyReportDetails'; +import CameraScreen from './screens/CameraScreen'; +import CacheScreen from './screens/CacheScreen'; +import NearbyReportsScreen from './screens/NearbyReportsScreen'; +import ReportDetails from './screens/ReportDetails'; +import ReviewCameraScreen from './screens/ReviewCameraScreen'; +import MapScreen from './screens/MapScreen'; +import SortScreen from './screens/SortScreen'; +import { + getFirstRun, + getPrivacySetting, + getUserName, + getWalletAddress, + isPrivacyAndTermsAccepted, +} from './services/DataManager'; +import { + updateOrCreateUser, + updatePrivacyAndTOC, +} from './services/API/APIManager'; +import {createStackNavigator} from '@react-navigation/stack'; +import {useStateValue} from './services/State/State'; +import {useNotifiedReports} from './hooks/useReadReports'; +import {ReportsProvider} from './contexts/ReportsContext'; +import {ToastifyManager} from './components/ToastifyToast'; import { flushPendingNavigationActions, navigationRef, } from './services/NavigationService'; - -enableScreens(); - -const Tab = createBottomTabNavigator(); -const Stack = createStackNavigator(); - -const ReportsStack = ({ - markReportAsRead, - markReportAsOpened, - openedReports, -}) => { - return ( - - - - - - - - ); -}; - -const LeaderboardStack = () => { - return ( - - - - - ); -}; - -// Create a memoized component to avoid inline function issues -const MemoizedReportsStack = React.memo(ReportsStack); -const MemoizedLeaderboardStack = React.memo(LeaderboardStack); - -const BottomTabs = ({ navigation }) => { - const insets = useSafeAreaInsets(); - const [{ reports }] = useStateValue(); - const { - notifiedReports, - openedReports, - isNewReport, - isReportOpened, - toastMessage, - showToast, - hideToast, - saveNotifiedReports, - clearNotifiedReports, - setToastMessage, - setShowToast, - markReportAsRead, - markReportAsOpened, - } = useNotifiedReports(reports); - - return ( - <> - - - - ( - - ), - }} - /> - ( - - ), - }}> - {() => } - - ({ - tabBarLabel: 'Camera', - tabBarItemStyle: { - flex: 1.5, - }, - tabBarButton: props => ( - { - // Navigate to camera if not already there, or trigger photo - if (navigation.isFocused()) { - // Already on camera - trigger photo via navigation params - navigation.setParams({ takePhoto: Date.now() }); - } else { - navigation.navigate('Camera'); - } - }} - onLongPress={() => { - // Navigate to camera if not already there, or trigger photo with annotation - if (navigation.isFocused()) { - navigation.setParams({ takePhotoWithAnnotation: Date.now() }); - } else { - navigation.navigate('Camera', { takePhotoWithAnnotation: Date.now() }); - } - }} - openedReports={openedReports} - /> - ), - })} - /> - ( - - ), - }}> - {() => ( - - )} - - ( - - ), - }} - /> - - - ); -}; - -const RootScreen = () => { - useEffect(() => { - getFirstRun().then(async ret => { - if (ret && ret.firstRun) { - const walletAddress = await getWalletAddress(); - const userAvatar = await getUserName(); - if (!walletAddress || !userAvatar || !userAvatar.userName) { - await onboard(); - return; - } - const privacySetting = await getPrivacySetting(); - const termAccepted = await isPrivacyAndTermsAccepted(); - await updateOrCreateUser(walletAddress, userAvatar.userName); - await updatePrivacyAndTOC( - walletAddress, - privacySetting === 0 ? 'share_data_live' : 'not_share_data_live', - termAccepted ? 'ACCEPTED' : 'REJECTED', - ); - } else { - await onboard(); - } - }); - }, []); - - return ; -}; - -const CreateRootNavigator = () => { - return ( + +enableScreens(); + +const Tab = createBottomTabNavigator(); +const Stack = createStackNavigator(); + +const ReportsStack = ({ + markReportAsRead, + markReportAsOpened, + openedReports, +}) => { + return ( + + + + + + + + ); +}; + +const LeaderboardStack = () => { + return ( + + + + + ); +}; + +const CameraStack = () => { + return ( + + + + + ); +}; + +// Create a memoized component to avoid inline function issues +const MemoizedReportsStack = React.memo(ReportsStack); +const MemoizedLeaderboardStack = React.memo(LeaderboardStack); +const MemoizedCameraStack = React.memo(CameraStack); + +const BottomTabs = ({navigation}) => { + const insets = useSafeAreaInsets(); + const [{reports}] = useStateValue(); + const { + notifiedReports, + openedReports, + isNewReport, + isReportOpened, + toastMessage, + showToast, + hideToast, + saveNotifiedReports, + clearNotifiedReports, + setToastMessage, + setShowToast, + markReportAsRead, + markReportAsOpened, + } = useNotifiedReports(reports); + + return ( + <> + + + + ( + + ), + }} + /> + ( + + ), + }}> + {() => } + + ({ + tabBarLabel: 'Camera', + tabBarItemStyle: { + flex: 1.5, + }, + tabBarButton: props => ( + { + if (navigation.isFocused()) { + navigation.navigate('Camera', { + screen: 'CameraHome', + params: {takePhoto: Date.now()}, + }); + return; + } + navigation.navigate('Camera', { + screen: 'CameraHome', + }); + }} + onLongPress={() => { + if (navigation.isFocused()) { + navigation.navigate('Camera', { + screen: 'CameraHome', + params: {takePhotoWithAnnotation: Date.now()}, + }); + return; + } + navigation.navigate('Camera', { + screen: 'CameraHome', + }); + }} + openedReports={openedReports} + /> + ), + })}> + {() => } + + ( + + ), + }}> + {() => ( + + )} + + ( + + ), + }} + /> + + + ); +}; + +const RootScreen = () => { + useEffect(() => { + getFirstRun().then(async ret => { + if (ret && ret.firstRun) { + const walletAddress = await getWalletAddress(); + const userAvatar = await getUserName(); + if (!walletAddress || !userAvatar || !userAvatar.userName) { + await onboard(); + return; + } + const privacySetting = await getPrivacySetting(); + const termAccepted = await isPrivacyAndTermsAccepted(); + await updateOrCreateUser(walletAddress, userAvatar.userName); + await updatePrivacyAndTOC( + walletAddress, + privacySetting === 0 ? 'share_data_live' : 'not_share_data_live', + termAccepted ? 'ACCEPTED' : 'REJECTED', + ); + } else { + await onboard(); + } + }); + }, []); + + return ; +}; + +const CreateRootNavigator = () => { + return ( - - - ); -}; - -export default CreateRootNavigator; + + + ); +}; + +export default CreateRootNavigator; diff --git a/src/languages/Chinese.js b/src/languages/Chinese.js index 0c1531a..067537d 100644 --- a/src/languages/Chinese.js +++ b/src/languages/Chinese.js @@ -29,6 +29,42 @@ export default { cancel: '取消', submit: '提交', upload: '上传', + sort: 'Sort', + }, + sortscreen: { + back: 'Back', + eyebrow: 'SORT', + title: 'Swipe right for high value. Hold to rank urgency.', + subtitle: + 'Every accepted sort pays 1 KITN for now while we train the consensus model.', + loadError: 'Unable to load a report to sort right now.', + walletRequired: 'A wallet is required before you can start sorting.', + alreadySorted: 'Already sorted', + loadingAnother: 'Loading another report now.', + submitError: 'Unable to submit that sort right now.', + kitn: 'KITN', + cardHint: 'Swipe right = High Value • Swipe left or hold = Spam', + waiting: 'Loading your next report...', + sorts: 'sorts', + meanUrgency: 'mean urgency', + session: 'session', + spam: 'Spam', + spamHint: 'Swipe left or hold for urgency', + highValue: 'High Value', + highValueHint: 'Swipe right', + submitting: 'Submitting your sort...', + loading: 'Loading the next report...', + emptyTitle: 'Queue complete', + emptyBody: + 'You have already sorted every report currently available to you.', + retry: 'Retry', + imageUnavailable: 'Image unavailable for this report.', + problem: 'Something went wrong', + urgency: 'Urgency', + exitTitle: 'Exit Sort', + exitBody: 'Return to the main camera screen?', + stay: 'Stay', + leave: 'Leave', }, }, }; diff --git a/src/languages/English.js b/src/languages/English.js index 827b76d..04e8b69 100644 --- a/src/languages/English.js +++ b/src/languages/English.js @@ -152,10 +152,46 @@ export default { cancel: 'Cancel', submit: 'Submit', upload: 'Upload', + sort: 'Sort', nocameraavailable: 'No camera available on this device', cameranotready: 'Camera is not ready', retry: 'Retry', }, + sortscreen: { + back: 'Back', + eyebrow: 'SORT', + title: 'Swipe right for high value. Hold to rank urgency.', + subtitle: + 'Every accepted sort pays 1 KITN for now while we train the consensus model.', + loadError: 'Unable to load a report to sort right now.', + walletRequired: 'A wallet is required before you can start sorting.', + alreadySorted: 'Already sorted', + loadingAnother: 'Loading another report now.', + submitError: 'Unable to submit that sort right now.', + kitn: 'KITN', + cardHint: 'Swipe right = High Value • Swipe left or hold = Spam', + waiting: 'Loading your next report...', + sorts: 'sorts', + meanUrgency: 'mean urgency', + session: 'session', + spam: 'Spam', + spamHint: 'Swipe left or hold for urgency', + highValue: 'High Value', + highValueHint: 'Swipe right', + submitting: 'Submitting your sort...', + loading: 'Loading the next report...', + emptyTitle: 'Queue complete', + emptyBody: + 'You have already sorted every report currently available to you.', + retry: 'Retry', + imageUnavailable: 'Image unavailable for this report.', + problem: 'Something went wrong', + urgency: 'Urgency', + exitTitle: 'Exit Sort', + exitBody: 'Return to the main camera screen?', + stay: 'Stay', + leave: 'Leave', + }, cachescreen: { copiedtoclipboard: 'Copied to clipboard', Sharingmyreferralcode: 'Sharing my referral code', diff --git a/src/languages/German.js b/src/languages/German.js index 0479f97..09c2495 100644 --- a/src/languages/German.js +++ b/src/languages/German.js @@ -31,6 +31,42 @@ export default { cancel: 'Abbrechen', submit: 'Einreichen', upload: 'Hochladen', + sort: 'Sort', + }, + sortscreen: { + back: 'Back', + eyebrow: 'SORT', + title: 'Swipe right for high value. Hold to rank urgency.', + subtitle: + 'Every accepted sort pays 1 KITN for now while we train the consensus model.', + loadError: 'Unable to load a report to sort right now.', + walletRequired: 'A wallet is required before you can start sorting.', + alreadySorted: 'Already sorted', + loadingAnother: 'Loading another report now.', + submitError: 'Unable to submit that sort right now.', + kitn: 'KITN', + cardHint: 'Swipe right = High Value • Swipe left or hold = Spam', + waiting: 'Loading your next report...', + sorts: 'sorts', + meanUrgency: 'mean urgency', + session: 'session', + spam: 'Spam', + spamHint: 'Swipe left or hold for urgency', + highValue: 'High Value', + highValueHint: 'Swipe right', + submitting: 'Submitting your sort...', + loading: 'Loading the next report...', + emptyTitle: 'Queue complete', + emptyBody: + 'You have already sorted every report currently available to you.', + retry: 'Retry', + imageUnavailable: 'Image unavailable for this report.', + problem: 'Something went wrong', + urgency: 'Urgency', + exitTitle: 'Exit Sort', + exitBody: 'Return to the main camera screen?', + stay: 'Stay', + leave: 'Leave', }, }, }; diff --git a/src/languages/Japanese.js b/src/languages/Japanese.js index ee2cb75..9e208b5 100644 --- a/src/languages/Japanese.js +++ b/src/languages/Japanese.js @@ -30,6 +30,42 @@ export default { cancel: 'キャンセル', submit: '送信', upload: 'アップロード', + sort: 'Sort', + }, + sortscreen: { + back: 'Back', + eyebrow: 'SORT', + title: 'Swipe right for high value. Hold to rank urgency.', + subtitle: + 'Every accepted sort pays 1 KITN for now while we train the consensus model.', + loadError: 'Unable to load a report to sort right now.', + walletRequired: 'A wallet is required before you can start sorting.', + alreadySorted: 'Already sorted', + loadingAnother: 'Loading another report now.', + submitError: 'Unable to submit that sort right now.', + kitn: 'KITN', + cardHint: 'Swipe right = High Value • Swipe left or hold = Spam', + waiting: 'Loading your next report...', + sorts: 'sorts', + meanUrgency: 'mean urgency', + session: 'session', + spam: 'Spam', + spamHint: 'Swipe left or hold for urgency', + highValue: 'High Value', + highValueHint: 'Swipe right', + submitting: 'Submitting your sort...', + loading: 'Loading the next report...', + emptyTitle: 'Queue complete', + emptyBody: + 'You have already sorted every report currently available to you.', + retry: 'Retry', + imageUnavailable: 'Image unavailable for this report.', + problem: 'Something went wrong', + urgency: 'Urgency', + exitTitle: 'Exit Sort', + exitBody: 'Return to the main camera screen?', + stay: 'Stay', + leave: 'Leave', }, }, }; diff --git a/src/languages/Spanish.js b/src/languages/Spanish.js index 42e31ff..d3273bb 100644 --- a/src/languages/Spanish.js +++ b/src/languages/Spanish.js @@ -33,6 +33,42 @@ export default { cancel: 'Cancelar', submit: 'Enviar', upload: 'Subir', + sort: 'Sort', + }, + sortscreen: { + back: 'Back', + eyebrow: 'SORT', + title: 'Swipe right for high value. Hold to rank urgency.', + subtitle: + 'Every accepted sort pays 1 KITN for now while we train the consensus model.', + loadError: 'Unable to load a report to sort right now.', + walletRequired: 'A wallet is required before you can start sorting.', + alreadySorted: 'Already sorted', + loadingAnother: 'Loading another report now.', + submitError: 'Unable to submit that sort right now.', + kitn: 'KITN', + cardHint: 'Swipe right = High Value • Swipe left or hold = Spam', + waiting: 'Loading your next report...', + sorts: 'sorts', + meanUrgency: 'mean urgency', + session: 'session', + spam: 'Spam', + spamHint: 'Swipe left or hold for urgency', + highValue: 'High Value', + highValueHint: 'Swipe right', + submitting: 'Submitting your sort...', + loading: 'Loading the next report...', + emptyTitle: 'Queue complete', + emptyBody: + 'You have already sorted every report currently available to you.', + retry: 'Retry', + imageUnavailable: 'Image unavailable for this report.', + problem: 'Something went wrong', + urgency: 'Urgency', + exitTitle: 'Exit Sort', + exitBody: 'Return to the main camera screen?', + stay: 'Stay', + leave: 'Leave', }, }, }; diff --git a/src/screens/CameraScreen.js b/src/screens/CameraScreen.js index 9775d75..67fae25 100644 --- a/src/screens/CameraScreen.js +++ b/src/screens/CameraScreen.js @@ -1,5 +1,4 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; import { Alert, Animated, @@ -18,7 +17,7 @@ import { Linking, Easing, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import {SafeAreaView} from 'react-native-safe-area-context'; import { Gesture, GestureDetector, @@ -37,35 +36,32 @@ import { useCameraPermission, } from 'react-native-vision-camera'; import RNFS from 'react-native-fs'; -import { useIsFocused, useNavigation, useRoute } from '@react-navigation/native'; -import { launchImageLibrary } from 'react-native-image-picker'; -import { check, request, PERMISSIONS, RESULTS } from 'react-native-permissions'; +import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; +import {launchImageLibrary} from 'react-native-image-picker'; +import {check, request, PERMISSIONS, RESULTS} from 'react-native-permissions'; import ImageResizer from '@bam.tech/react-native-image-resizer'; -import { theme } from '../services/Common/theme'; -import { fontFamilies } from '../utils/fontFamilies'; +import {theme} from '../services/Common/theme'; +import {fontFamilies} from '../utils/fontFamilies'; import CheckBigIcon from '../assets/ico_check_big.svg'; import TargetIcon from '../assets/ico_target.svg'; -import { BlurView } from '@react-native-community/blur'; -import { useTranslation } from 'react-i18next'; -import { - report, - matchReports, -} from '../services/API/APIManager'; -import { getLocation } from '../functions/geolocation'; -import { getWalletAddress } from '../services/DataManager'; -import { ToastService } from '../components/ToastifyToast'; +import {BlurView} from '@react-native-community/blur'; +import {useTranslation} from 'react-i18next'; +import {report, matchReports} from '../services/API/APIManager'; +import {getLocation} from '../functions/geolocation'; +import {getWalletAddress} from '../services/DataManager'; +import {ToastService} from '../components/ToastifyToast'; import ReportDeliveryNotificationService from '../services/ReportDeliveryNotificationService'; -import Svg, { Circle } from 'react-native-svg'; +import Svg, {Circle} from 'react-native-svg'; const ROTATION_DURATION_MS = 3800; const CLEANAPP_DARK_GREEN = '#2F7A45'; // StoryCrosshair: Animated dial that tells the CleanApp story -const StoryCrosshair = ({ currentPrompt, isActive, rotationKey }) => { +const StoryCrosshair = ({currentPrompt, isActive, rotationKey}) => { const MOTIF_ROTATIONS_INTERVAL = 4; - const { width } = Dimensions.get('window'); + const {width} = Dimensions.get('window'); // Dimensions for the crosshair container // The original target View was width / 1.5 const containerSize = width / 1.5; @@ -102,7 +98,7 @@ const StoryCrosshair = ({ currentPrompt, isActive, rotationKey }) => { duration: ROTATION_DURATION_MS, easing: Easing.linear, useNativeDriver: true, - }) + }), ); loop.start(); return () => loop.stop(); @@ -119,7 +115,7 @@ const StoryCrosshair = ({ currentPrompt, isActive, rotationKey }) => { } setShowMotifSequence( rotationCountRef.current > 0 && - rotationCountRef.current % MOTIF_ROTATIONS_INTERVAL === 0 + rotationCountRef.current % MOTIF_ROTATIONS_INTERVAL === 0, ); }, [isActive, rotationKey]); @@ -129,8 +125,7 @@ const StoryCrosshair = ({ currentPrompt, isActive, rotationKey }) => { }); const topPillText = showMotifSequence ? '1. REPORT' : currentPrompt; const rightPillText = showMotifSequence ? '2. REVIEW' : 'AI analysis'; - const leftPillText = - showMotifSequence ? '3. RESPOND' : 'Sent for action'; + const leftPillText = showMotifSequence ? '3. RESPOND' : 'Sent for action'; // Opacity for labels based on progress // 0-33%: 12 o'clock @@ -187,9 +182,19 @@ const StoryCrosshair = ({ currentPrompt, isActive, rotationKey }) => { }); return ( - + {/* 1. Underlying Crosshair Icon */} - + @@ -201,12 +206,11 @@ const StoryCrosshair = ({ currentPrompt, isActive, rotationKey }) => { style={[ StyleSheet.absoluteFillObject, { - transform: [{ rotate: '180deg' }, { rotate: spin }], + transform: [{rotate: '180deg'}, {rotate: spin}], }, - ]} - > + ]}> - {Array.from({ length: sliceCount }).map((_, index) => { + {Array.from({length: sliceCount}).map((_, index) => { const alpha = (1 - index / (sliceCount - 1)) * 0.6; const dashOffset = tailDashOffset + index * sliceLength; return ( @@ -241,17 +245,15 @@ const StoryCrosshair = ({ currentPrompt, isActive, rotationKey }) => { right: 0, alignItems: 'center', opacity: label1Opacity, - transform: [{ scale: label1Scale }, { translateY: label1TranslateY }], + transform: [{scale: label1Scale}, {translateY: label1TranslateY}], }, - ]} - > + ]}> + ]}> {topPillText} @@ -264,16 +266,14 @@ const StoryCrosshair = ({ currentPrompt, isActive, rotationKey }) => { bottom: -26, right: -56, opacity: label2Opacity, - transform: [{ scale: label2Scale }, { translateY: label2TranslateY }], + transform: [{scale: label2Scale}, {translateY: label2TranslateY}], }, - ]} - > + ]}> + ]}> {rightPillText} @@ -286,21 +286,18 @@ const StoryCrosshair = ({ currentPrompt, isActive, rotationKey }) => { bottom: -26, left: -56, opacity: label3Opacity, - transform: [{ scale: label3Scale }, { translateY: label3TranslateY }], + transform: [{scale: label3Scale}, {translateY: label3TranslateY}], }, - ]} - > + ]}> + ]}> {leftPillText} - ); }; @@ -313,8 +310,8 @@ Reanimated.addWhitelistedNativeProps({ const ReanimatedCamera = Reanimated.createAnimatedComponent(Camera); // Premium shutter flash - radiates from bullseye center -const ShutterFlash = ({ isActive }) => { - const { width, height } = Dimensions.get('window'); +const ShutterFlash = ({isActive}) => { + const {width, height} = Dimensions.get('window'); // Bullseye center is offset 50px above screen center const bullseyeCenterY = height / 2 - 50; @@ -429,7 +426,7 @@ const ShutterFlash = ({ isActive }) => { borderRadius: width, backgroundColor: '#59E480', opacity: flashOpacity, - transform: [{ scale: flashScale }], + transform: [{scale: flashScale}], }} /> @@ -445,9 +442,9 @@ const ShutterFlash = ({ isActive }) => { borderWidth: 5, borderColor: '#FFFFFF', opacity: ringOpacity, - transform: [{ scale: ringScale }], + transform: [{scale: ringScale}], shadowColor: '#59E480', - shadowOffset: { width: 0, height: 0 }, + shadowOffset: {width: 0, height: 0}, shadowOpacity: 1, shadowRadius: 25, }} @@ -461,7 +458,7 @@ const ShutterFlash = ({ isActive }) => { borderColor: '#59E480', opacity: glowIntensity, shadowColor: '#59E480', - shadowOffset: { width: 0, height: 0 }, + shadowOffset: {width: 0, height: 0}, shadowOpacity: 1, shadowRadius: 40, }} @@ -472,8 +469,8 @@ const ShutterFlash = ({ isActive }) => { // Animated reward circle - lightweight animations using native driver // These run on the native UI thread, not JS, so zero performance impact on older phones -const RewardCircle = ({ t }) => { - const { width, height } = Dimensions.get('screen'); +const RewardCircle = ({t}) => { + const {width, height} = Dimensions.get('screen'); // Animation values - all use native driver for performance const circleScale = useRef(new Animated.Value(0.8)).current; @@ -502,7 +499,7 @@ const RewardCircle = ({ t }) => { duration: 600, useNativeDriver: true, }), - ]) + ]), ).start(); // Checkmark pops in, then grows bigger before disappearing @@ -551,12 +548,12 @@ const RewardCircle = ({ t }) => { justifyContent: 'center', alignItems: 'center', opacity: circleOpacity, - transform: [{ scale: circleScale }], + transform: [{scale: circleScale}], }}> @@ -663,7 +660,7 @@ const shuffleArray = input => { }; const CameraScreen = props => { - const { reportId } = props; + const {reportId} = props; const navigation = useNavigation(); const route = useRoute(); const isReviewMode = useMemo(() => { @@ -684,15 +681,12 @@ const CameraScreen = props => { useState(false); const [currentPromptIndex, setCurrentPromptIndex] = useState(0); - const shuffledPrompts = useMemo( - () => shuffleArray(CLEANAPP_PROMPTS), - [], - ); + const shuffledPrompts = useMemo(() => shuffleArray(CLEANAPP_PROMPTS), []); // promptOpacity removed as it is handled in StoryCrosshair now (or simpler rotation) const tapAnimatedValue = useRef(new Animated.Value(0.2)); const tapScale = useState(0); - const { t } = useTranslation(); - const { hasPermission, requestPermission } = useCameraPermission(); + const {t} = useTranslation(); + const {hasPermission, requestPermission} = useCameraPermission(); const isFocused = useIsFocused(); @@ -846,13 +840,13 @@ const CameraScreen = props => { t('camerascreen.notice'), t('camerascreen.photolibraryaccesspermissionnotgranted'), [ - { text: t('camerascreen.no'), style: 'cancel' }, + {text: t('camerascreen.no'), style: 'cancel'}, { text: t('camerascreen.yes'), onPress: () => Linking.openSettings(), }, ], - { cancelable: false }, + {cancelable: false}, ); setHasPhotoLibraryPermission(false); return false; @@ -877,8 +871,8 @@ const CameraScreen = props => { Alert.alert( t('camerascreen.notice'), t('camerascreen.invalidlocation'), - [{ text: t('camerascreen.ok'), onPress: () => { } }], - { cancelable: false }, + [{text: t('camerascreen.ok'), onPress: () => {}}], + {cancelable: false}, ); return null; } @@ -917,18 +911,16 @@ const CameraScreen = props => { Alert.alert( t('camerascreen.notice'), t('camerascreen.invalidlocation'), - [{ text: t('camerascreen.ok'), onPress: () => { } }], - { cancelable: false }, + [{text: t('camerascreen.ok'), onPress: () => {}}], + {cancelable: false}, ); return null; } }; const showMatchReportsResult = res => { - const resolvedMessage = - '+2 KITN for verification'; - const reportMessage = - '+1 KITN for reporting'; + const resolvedMessage = '+2 KITN for verification'; + const reportMessage = '+1 KITN for reporting'; try { if (res.success && res.success === true) { if (res.results.length > 0) { @@ -986,8 +978,8 @@ const CameraScreen = props => { Alert.alert( t('camerascreen.notice'), 'Camera not available', - [{ text: t('camerascreen.ok'), onPress: () => { } }], - { cancelable: false }, + [{text: t('camerascreen.ok'), onPress: () => {}}], + {cancelable: false}, ); return; } @@ -1019,16 +1011,16 @@ const CameraScreen = props => { Alert.alert( t('camerascreen.notice'), t('camerascreen.failedtosaveimage') + err.message, - [{ text: t('camerascreen.ok'), onPress: () => { } }], - { cancelable: false }, + [{text: t('camerascreen.ok'), onPress: () => {}}], + {cancelable: false}, ); }); if (!res.ok) { Alert.alert( t('camerascreen.notice'), t('camerascreen.failedtosaveimage') + res.error, - [{ text: t('camerascreen.ok'), onPress: () => { } }], - { cancelable: false }, + [{text: t('camerascreen.ok'), onPress: () => {}}], + {cancelable: false}, ); return; } @@ -1053,8 +1045,8 @@ const CameraScreen = props => { Alert.alert( t('camerascreen.notice'), t('camerascreen.failedtotakephoto') + e.message, - [{ text: t('camerascreen.ok'), onPress: () => { } }], - { cancelable: false }, + [{text: t('camerascreen.ok'), onPress: () => {}}], + {cancelable: false}, ); } } finally { @@ -1080,8 +1072,8 @@ const CameraScreen = props => { Alert.alert( t('camerascreen.notice'), t('camerascreen.failedtosaveimage') + err.message, - [{ text: t('camerascreen.ok'), onPress: () => { } }], - { cancelable: false }, + [{text: t('camerascreen.ok'), onPress: () => {}}], + {cancelable: false}, ); }); @@ -1089,8 +1081,8 @@ const CameraScreen = props => { Alert.alert( t('camerascreen.notice'), t('camerascreen.failedtosaveimage') + (res?.error || 'Unknown error'), - [{ text: t('camerascreen.ok'), onPress: () => { } }], - { cancelable: false }, + [{text: t('camerascreen.ok'), onPress: () => {}}], + {cancelable: false}, ); return; } @@ -1116,8 +1108,8 @@ const CameraScreen = props => { Alert.alert( t('camerascreen.notice'), t('camerascreen.failedtosaveimage') + e.message, - [{ text: t('camerascreen.ok'), onPress: () => { } }], - { cancelable: false }, + [{text: t('camerascreen.ok'), onPress: () => {}}], + {cancelable: false}, ); } }; @@ -1171,8 +1163,8 @@ const CameraScreen = props => { Alert.alert( t('camerascreen.notice'), 'Failed to select photo: ' + result.errorMessage, - [{ text: t('camerascreen.ok'), onPress: () => { } }], - { cancelable: false }, + [{text: t('camerascreen.ok'), onPress: () => {}}], + {cancelable: false}, ); return; } @@ -1199,8 +1191,8 @@ const CameraScreen = props => { Alert.alert( t('camerascreen.notice'), 'Failed to open gallery: ' + error.message, - [{ text: t('camerascreen.ok'), onPress: () => { } }], - { cancelable: false }, + [{text: t('camerascreen.ok'), onPress: () => {}}], + {cancelable: false}, ); } finally { // Clean up original photo file if it exists and is different from resized @@ -1230,7 +1222,7 @@ const CameraScreen = props => { const allGestures = Gesture.Race(pinchGesture); - const animatedProps = useAnimatedProps(() => ({ zoom: zoom.value }), [zoom]); + const animatedProps = useAnimatedProps(() => ({zoom: zoom.value}), [zoom]); // Function to resize photo to height 1000 while preserving aspect ratio const resizePhoto = async photoUri => { @@ -1245,7 +1237,7 @@ const CameraScreen = props => { 0, // rotation undefined, // outputPath false, // keepMetadata - { mode: 'contain', onlyScaleDown: false }, + {mode: 'contain', onlyScaleDown: false}, ); // The resized image will maintain aspect ratio with height 1000 @@ -1287,15 +1279,15 @@ const CameraScreen = props => { Alert.alert( t('camerascreen.notice'), 'Camera configuration error: ' + error.message, - [{ text: t('camerascreen.ok'), onPress: () => { } }], - { cancelable: false }, + [{text: t('camerascreen.ok'), onPress: () => {}}], + {cancelable: false}, ); }} /> )} {isInAnnotationMode && photoData && ( @@ -1313,8 +1305,7 @@ const CameraScreen = props => { alignItems: 'center', marginTop: -130, // Higher to use top space }} - pointerEvents="box-none" - > + pointerEvents="box-none"> { )} - {phototaken && ( - - )} + {phototaken && } {!phototaken && !isInAnnotationMode && ( <> + {/* Sort Button */} + {!isReviewMode && ( + navigation.navigate('SortGame')}> + + {t('camerascreen.sort') || 'Sort'} + + + )} + {/* Upload Button */} ; }) => { const navigation = useNavigation(); - const {t} = useTranslation(); const {report: reportItem} = route.params; const report = reportItem.report; - const [relatedCases, setRelatedCases] = useState([]); - var englishAnalysis = reportItem.analysis[0]; + const [relatedCases, setRelatedCases] = useState([]); + let englishAnalysis = reportItem.analysis[0]; for (const analysisItem of reportItem.analysis) { if (analysisItem.language === 'en') { englishAnalysis = analysisItem; diff --git a/src/screens/Onboarding.js b/src/screens/Onboarding.js index e8f3889..1085a13 100644 --- a/src/screens/Onboarding.js +++ b/src/screens/Onboarding.js @@ -1,5 +1,4 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -import { StackActions } from '@react-navigation/native'; +import { StackActions } from '@react-navigation/native'; import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { diff --git a/src/screens/ReportDetails.tsx b/src/screens/ReportDetails.tsx index f5ed39a..4edef9d 100644 --- a/src/screens/ReportDetails.tsx +++ b/src/screens/ReportDetails.tsx @@ -7,7 +7,6 @@ import { Text, View, ScrollView, - Image, Dimensions, Linking, Alert, @@ -16,15 +15,14 @@ import { import {SafeAreaView} from 'react-native-safe-area-context'; import {theme} from '../services/Common/theme'; import {fontFamilies} from '../utils/fontFamilies'; -import {useTranslation} from 'react-i18next'; import ResponsiveImage from '../components/ResponsiveImage'; import ChevronLeft from '../components/ChevronLeft'; import NavigationIcon from '../components/NavigationIcon'; -import {getLocation} from '../functions/geolocation'; -import {calculateDistance} from '../utils/calculateDistance'; import {useReverseGeocoding} from '../hooks/useReverseGeocoding'; import {readReportCases} from '../services/API/APIManager'; -import CaseAwarenessCard from '../components/CaseAwarenessCard'; +import CaseAwarenessCard, { + type CaseAwarenessCase, +} from '../components/CaseAwarenessCard'; // import {useReportsContext} from '../contexts/ReportsContext'; type ReportsStackParamList = { @@ -47,9 +45,8 @@ const ReportDetails = ({ >; }) => { const navigation = useNavigation(); - const {t} = useTranslation(); const {report} = route.params; - const [relatedCases, setRelatedCases] = useState([]); + const [relatedCases, setRelatedCases] = useState([]); // Reverse geocoding hook to get human-readable address const { @@ -64,23 +61,6 @@ const ReportDetails = ({ autoFetch: true, }); - const checkDistanceFromReport = async (): Promise => { - console.log('Checking distance from report'); - const userLocation = await getLocation(); - console.log('User location is ', userLocation); - - console.log('Calculating distance'); - const distance = calculateDistance( - userLocation.latitude, - userLocation.longitude, - report.latitude, - report.longitude, - ); - - console.log('Distance is ', distance, ' meters'); - return distance; - }; - const goBack = () => { navigation.goBack(); }; diff --git a/src/screens/SortScreen.tsx b/src/screens/SortScreen.tsx new file mode 100644 index 0000000..7bad431 --- /dev/null +++ b/src/screens/SortScreen.tsx @@ -0,0 +1,1367 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import { + ActivityIndicator, + Animated, + Dimensions, + Easing, + Image, + PanResponder, + SafeAreaView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; +import {useFocusEffect, useNavigation} from '@react-navigation/native'; +import {useTranslation} from 'react-i18next'; + +import {GetOrCreateLocalWallet} from '../functions/login'; +import {fontFamilies} from '../utils/fontFamilies'; +import {theme} from '../services/Common/theme'; +import {getUrls} from '../services/API/Settings'; +import {getNextSortReport, submitSortReport} from '../services/API/APIManager'; +import {getWalletAddress, setCacheVault} from '../services/DataManager'; +import {ToastService} from '../components/ToastifyToast'; +import {useStateValue} from '../services/State/State'; +import {actions} from '../services/State/Reducer'; + +const {width: screenWidth} = Dimensions.get('window'); + +const SWIPE_THRESHOLD = 110; +const HOLD_CANCEL_DISTANCE = 18; +const HOLD_DURATION_MS = 3000; +const THERMOMETER_HEIGHT = 168; +const SORT_FETCH_ATTEMPTS = 8; + +type SortVerdict = 'high_value' | 'spam'; + +interface SortMetrics { + report_seq: number; + sort_count: number; + high_value_count: number; + spam_count: number; + urgency_sum: number; + urgency_mean: number; +} + +interface SortCandidate { + report: { + seq: number; + public_id: string; + timestamp: string; + }; + sort_metrics: SortMetrics; +} + +interface PreparedSortCandidate { + candidate: SortCandidate; + imageUrl: string; + prefetched: boolean; +} + +interface CandidateLoadResult { + ok: boolean; + prepared?: PreparedSortCandidate; + empty?: boolean; + error?: string; +} + +const buildRawImageUrl = (seq?: number) => { + if (!seq) { + return ''; + } + const urls = getUrls(); + if (!urls?.liveUrl) { + return ''; + } + return `${urls.liveUrl}/api/v3/reports/rawimage?seq=${seq}`; +}; + +const clampUrgency = (value: number) => Math.max(0, Math.min(10, value)); + +const SortScreen = () => { + const navigation = useNavigation(); + const {t} = useTranslation(); + const [{cacheVault}, dispatch] = useStateValue(); + + const [sorterId, setSorterId] = useState(''); + const [activeCard, setActiveCard] = useState( + null, + ); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [emptyState, setEmptyState] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [urgencyScore, setUrgencyScore] = useState(0); + const [sessionKitns, setSessionKitns] = useState(0); + const [imageReady, setImageReady] = useState(false); + const [imageFailed, setImageFailed] = useState(false); + + const translateX = useRef(new Animated.Value(0)).current; + const cardOpacity = useRef(new Animated.Value(1)).current; + const holdProgress = useRef(new Animated.Value(0)).current; + const holdValueRef = useRef(0); + const isGestureCancelledRef = useRef(false); + const prefetchedCardRef = useRef(null); + const prefetchSourceSeqRef = useRef(null); + const prefetchPromiseRef = useRef | null>(null); + + const rotation = useMemo( + () => + translateX.interpolate({ + inputRange: [-screenWidth * 0.6, 0, screenWidth * 0.6], + outputRange: ['-8deg', '0deg', '8deg'], + extrapolate: 'clamp', + }), + [translateX], + ); + + const spamCueOpacity = useMemo( + () => + translateX.interpolate({ + inputRange: [-screenWidth * 0.4, -40, 0], + outputRange: [1, 0.72, 0.3], + extrapolate: 'clamp', + }), + [translateX], + ); + + const highValueCueOpacity = useMemo( + () => + translateX.interpolate({ + inputRange: [0, 40, screenWidth * 0.4], + outputRange: [0.3, 0.72, 1], + extrapolate: 'clamp', + }), + [translateX], + ); + + const cuePulse = useRef(new Animated.Value(0)).current; + + const thermometerFillHeight = useMemo( + () => + holdProgress.interpolate({ + inputRange: [0, 1], + outputRange: [0, THERMOMETER_HEIGHT], + extrapolate: 'clamp', + }), + [holdProgress], + ); + + const leftArrowTranslateX = useMemo( + () => + cuePulse.interpolate({ + inputRange: [0, 1], + outputRange: [0, -10], + }), + [cuePulse], + ); + + const rightArrowTranslateX = useMemo( + () => + cuePulse.interpolate({ + inputRange: [0, 1], + outputRange: [0, 10], + }), + [cuePulse], + ); + + const cueGlowOpacity = useMemo( + () => + cuePulse.interpolate({ + inputRange: [0, 1], + outputRange: [0.45, 0.9], + }), + [cuePulse], + ); + + const cueScale = useMemo( + () => + cuePulse.interpolate({ + inputRange: [0, 1], + outputRange: [1, 1.08], + }), + [cuePulse], + ); + + useEffect(() => { + const listenerId = holdProgress.addListener(({value}) => { + holdValueRef.current = value; + setUrgencyScore(clampUrgency(Math.round(value * 10))); + }); + + return () => { + holdProgress.removeListener(listenerId); + }; + }, [holdProgress]); + + useEffect(() => { + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(cuePulse, { + toValue: 1, + duration: 900, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(cuePulse, { + toValue: 0, + duration: 900, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + ]), + ); + + loop.start(); + + return () => { + loop.stop(); + cuePulse.stopAnimation(); + }; + }, [cuePulse]); + + const clearGestureValues = useCallback(() => { + holdProgress.stopAnimation(); + holdProgress.setValue(0); + holdValueRef.current = 0; + setUrgencyScore(0); + isGestureCancelledRef.current = false; + translateX.stopAnimation(); + translateX.setValue(0); + }, [holdProgress, translateX]); + + const resetGestureState = useCallback(() => { + clearGestureValues(); + + Animated.parallel([ + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + friction: 7, + tension: 90, + }), + Animated.timing(cardOpacity, { + toValue: 1, + duration: 160, + useNativeDriver: true, + }), + ]).start(); + }, [cardOpacity, clearGestureValues, translateX]); + + const startHoldAnimation = useCallback(() => { + holdProgress.stopAnimation(); + holdProgress.setValue(0); + holdValueRef.current = 0; + setUrgencyScore(0); + Animated.timing(holdProgress, { + toValue: 1, + duration: HOLD_DURATION_MS, + easing: Easing.linear, + useNativeDriver: false, + }).start(); + }, [holdProgress]); + + const stagePreparedCandidate = useCallback( + (prepared: PreparedSortCandidate) => { + clearGestureValues(); + cardOpacity.stopAnimation(); + cardOpacity.setValue(0); + setActiveCard(prepared); + setImageReady(prepared.prefetched); + setImageFailed(false); + setErrorMessage(''); + setEmptyState(false); + Animated.timing(cardOpacity, { + toValue: 1, + duration: 240, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(); + }, + [cardOpacity, clearGestureValues], + ); + + const clearPrefetchedCandidate = useCallback(() => { + prefetchedCardRef.current = null; + prefetchSourceSeqRef.current = null; + prefetchPromiseRef.current = null; + }, []); + + const incrementLocalKitns = useCallback( + (rewardKitns: number) => { + if (!rewardKitns) { + return; + } + + const nextCacheVault = { + ...cacheVault, + reports: (cacheVault?.reports || 0) + rewardKitns, + dailyReports: (cacheVault?.dailyReports || 0) + rewardKitns, + dailyTotal: (cacheVault?.dailyTotal || 0) + rewardKitns, + total: (cacheVault?.total || 0) + rewardKitns, + offchainReports: (cacheVault?.offchainReports || 0) + rewardKitns, + offchainTotal: (cacheVault?.offchainTotal || 0) + rewardKitns, + }; + + dispatch({ + type: actions.SET_CACHE_VAULT, + cacheVault: nextCacheVault, + }); + void setCacheVault(nextCacheVault); + }, + [cacheVault, dispatch], + ); + + const fetchPreparedCandidate = useCallback( + async (excludedReportSeqs: number[] = []): Promise => { + if (!sorterId) { + return { + ok: false, + error: + t('sortscreen.loadError') || + 'Unable to load a report to sort right now.', + }; + } + + const excludedSeqSet = new Set( + excludedReportSeqs.filter(seq => Number.isInteger(seq) && seq > 0), + ); + + for (let attempt = 0; attempt < SORT_FETCH_ATTEMPTS; attempt += 1) { + const response = await getNextSortReport( + sorterId, + Array.from(excludedSeqSet), + ); + if (!response?.ok) { + if (response?.empty) { + return { + ok: false, + empty: true, + }; + } + + return { + ok: false, + error: + response?.error || + t('sortscreen.loadError') || + 'Unable to load a report to sort right now.', + }; + } + + const nextCandidate = response.candidate; + const nextSeq = nextCandidate?.report?.seq; + if (!nextSeq || excludedSeqSet.has(nextSeq)) { + continue; + } + + excludedSeqSet.add(nextSeq); + const imageUrl = buildRawImageUrl(nextSeq); + if (!imageUrl) { + continue; + } + + let prefetched = false; + try { + prefetched = await Image.prefetch(imageUrl); + } catch (err) { + prefetched = false; + } + + if (!prefetched) { + continue; + } + + return { + ok: true, + prepared: { + candidate: nextCandidate, + imageUrl, + prefetched, + }, + }; + } + + return { + ok: false, + error: + t('sortscreen.loadError') || + 'Unable to load a report to sort right now.', + }; + }, + [sorterId, t], + ); + + const applyCandidateLoadResult = useCallback( + (result: CandidateLoadResult) => { + if (result.ok && result.prepared) { + stagePreparedCandidate(result.prepared); + return; + } + + setActiveCard(null); + setImageReady(false); + setImageFailed(false); + if (result.empty) { + setEmptyState(true); + setErrorMessage(''); + return; + } + + setEmptyState(false); + setErrorMessage( + result.error || + t('sortscreen.loadError') || + 'Unable to load a report to sort right now.', + ); + }, + [stagePreparedCandidate, t], + ); + + const loadFreshCandidate = useCallback( + async (excludedReportSeqs: number[] = []) => { + if (!sorterId) { + return; + } + + setIsLoading(true); + setErrorMessage(''); + setEmptyState(false); + setImageReady(false); + setImageFailed(false); + clearPrefetchedCandidate(); + + const result = await fetchPreparedCandidate(excludedReportSeqs); + applyCandidateLoadResult(result); + setIsLoading(false); + }, + [ + applyCandidateLoadResult, + clearPrefetchedCandidate, + fetchPreparedCandidate, + sorterId, + ], + ); + + const queueNextCandidatePrefetch = useCallback( + (currentSeq?: number) => { + if (!sorterId || !currentSeq) { + return Promise.resolve(null); + } + + if ( + prefetchedCardRef.current && + prefetchSourceSeqRef.current === currentSeq + ) { + return Promise.resolve({ + ok: true, + prepared: prefetchedCardRef.current, + }); + } + + if ( + prefetchPromiseRef.current && + prefetchSourceSeqRef.current === currentSeq + ) { + return prefetchPromiseRef.current; + } + + prefetchSourceSeqRef.current = currentSeq; + const promise = fetchPreparedCandidate([currentSeq]) + .then(result => { + if (prefetchSourceSeqRef.current !== currentSeq) { + return result; + } + prefetchedCardRef.current = + result.ok && result.prepared ? result.prepared : null; + return result; + }) + .finally(() => { + if (prefetchSourceSeqRef.current === currentSeq) { + prefetchPromiseRef.current = null; + } + }); + + prefetchPromiseRef.current = promise; + return promise; + }, + [fetchPreparedCandidate, sorterId], + ); + + useEffect(() => { + let isMounted = true; + + const resolveSorterId = async () => { + setIsLoading(true); + setErrorMessage(''); + + let nextSorterId = await getWalletAddress(); + + if (!nextSorterId) { + const walletReady = await GetOrCreateLocalWallet(); + if (walletReady) { + nextSorterId = await getWalletAddress(); + } + } + + if (!isMounted) { + return; + } + + if (!nextSorterId) { + setIsLoading(false); + setErrorMessage( + t('sortscreen.loadError') || + 'Unable to load a report to sort right now.', + ); + return; + } + + setSorterId(String(nextSorterId)); + }; + + resolveSorterId(); + + return () => { + isMounted = false; + }; + }, [t]); + + useFocusEffect( + useCallback(() => { + if (!sorterId) { + return undefined; + } + + if (!activeCard && !isSubmitting) { + void loadFreshCandidate(); + return undefined; + } + + if (activeCard?.candidate?.report?.seq && !isSubmitting) { + void queueNextCandidatePrefetch(activeCard.candidate.report.seq); + } + return undefined; + }, [ + activeCard, + isSubmitting, + loadFreshCandidate, + queueNextCandidatePrefetch, + sorterId, + ]), + ); + + useEffect(() => { + const currentSeq = activeCard?.candidate?.report?.seq; + if (!sorterId || !currentSeq || isSubmitting) { + return; + } + + void queueNextCandidatePrefetch(currentSeq); + }, [activeCard, isSubmitting, queueNextCandidatePrefetch, sorterId]); + + const handleImageLoadError = useCallback(() => { + const failedSeq = activeCard?.candidate?.report?.seq; + + setImageReady(false); + setImageFailed(false); + + if (!failedSeq || isLoading || isSubmitting) { + setImageFailed(true); + return; + } + + setActiveCard(null); + void loadFreshCandidate([failedSeq]); + }, [activeCard, isLoading, isSubmitting, loadFreshCandidate]); + + const finishSort = useCallback( + async (verdict: SortVerdict, nextUrgency: number) => { + const currentCandidate = activeCard?.candidate; + const currentSeq = currentCandidate?.report?.seq; + if (!currentCandidate || !currentSeq || !sorterId || isSubmitting) { + return; + } + + setIsSubmitting(true); + setErrorMessage(''); + + const nextCandidatePromise = + prefetchedCardRef.current && prefetchSourceSeqRef.current === currentSeq + ? Promise.resolve({ + ok: true, + prepared: prefetchedCardRef.current, + }) + : queueNextCandidatePrefetch(currentSeq).then( + result => + result || { + ok: false, + error: + t('sortscreen.loadError') || + 'Unable to load a report to sort right now.', + }, + ); + + const response = await submitSortReport({ + sorterId, + reportSeq: currentSeq, + verdict, + urgencyScore: clampUrgency(nextUrgency), + }); + + if (!response?.ok) { + if (response?.status === 409) { + ToastService.show({ + type: 'info', + text1: t('sortscreen.alreadySorted') || 'Already sorted', + text2: + t('sortscreen.loadingAnother') || 'Loading another report now.', + }); + + const nextResult = await nextCandidatePromise; + if (prefetchSourceSeqRef.current === currentSeq) { + clearPrefetchedCandidate(); + } + applyCandidateLoadResult(nextResult); + setIsSubmitting(false); + return; + } + + setIsSubmitting(false); + setErrorMessage( + response?.error || + t('sortscreen.submitError') || + 'Unable to submit that sort right now.', + ); + resetGestureState(); + return; + } + + const rewardKitns = Number(response.submission?.reward_kitns || 0); + if (rewardKitns > 0) { + setSessionKitns(prev => prev + rewardKitns); + incrementLocalKitns(rewardKitns); + ToastService.success( + `+${rewardKitns} ${t('sortscreen.kitn') || 'KITN'}`, + 'top', + 2200, + ); + } + + const nextResult = await nextCandidatePromise; + if (prefetchSourceSeqRef.current === currentSeq) { + clearPrefetchedCandidate(); + } + applyCandidateLoadResult(nextResult); + setIsSubmitting(false); + }, + [ + activeCard, + applyCandidateLoadResult, + clearPrefetchedCandidate, + incrementLocalKitns, + isSubmitting, + queueNextCandidatePrefetch, + resetGestureState, + sorterId, + t, + ], + ); + + const animateCardOffscreen = useCallback( + (direction: 1 | -1, callback: () => void) => { + Animated.parallel([ + Animated.timing(translateX, { + toValue: direction * screenWidth, + duration: 190, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + Animated.timing(cardOpacity, { + toValue: 0.08, + duration: 190, + useNativeDriver: true, + }), + ]).start(() => { + callback(); + }); + }, + [cardOpacity, translateX], + ); + + const panResponder = useMemo( + () => + PanResponder.create({ + onStartShouldSetPanResponder: () => + !isSubmitting && + !!activeCard?.candidate && + imageReady && + !imageFailed && + !errorMessage && + !emptyState, + onMoveShouldSetPanResponder: () => + !isSubmitting && + !!activeCard?.candidate && + imageReady && + !imageFailed && + !errorMessage && + !emptyState, + onPanResponderGrant: () => { + startHoldAnimation(); + }, + onPanResponderMove: (_, gestureState) => { + const {dx, dy} = gestureState; + translateX.setValue(dx); + + if ( + !isGestureCancelledRef.current && + (Math.abs(dx) > HOLD_CANCEL_DISTANCE || + Math.abs(dy) > HOLD_CANCEL_DISTANCE) + ) { + isGestureCancelledRef.current = true; + holdProgress.stopAnimation(); + holdProgress.setValue(0); + holdValueRef.current = 0; + setUrgencyScore(0); + } + }, + onPanResponderRelease: (_, gestureState) => { + const {dx} = gestureState; + holdProgress.stopAnimation(value => { + const heldUrgency = clampUrgency(Math.round(value * 10)); + holdProgress.setValue(0); + + if (dx >= SWIPE_THRESHOLD) { + setUrgencyScore(0); + animateCardOffscreen(1, () => { + finishSort('high_value', 0); + }); + return; + } + + if (heldUrgency > 0) { + setUrgencyScore(0); + animateCardOffscreen(-1, () => { + finishSort('spam', heldUrgency); + }); + return; + } + + if (dx <= -SWIPE_THRESHOLD) { + setUrgencyScore(0); + animateCardOffscreen(-1, () => { + finishSort('spam', 0); + }); + return; + } + + resetGestureState(); + }); + }, + onPanResponderTerminate: () => { + resetGestureState(); + }, + }), + [ + activeCard, + animateCardOffscreen, + emptyState, + errorMessage, + finishSort, + holdProgress, + imageFailed, + imageReady, + isSubmitting, + resetGestureState, + startHoldAnimation, + translateX, + ], + ); + + const candidate = activeCard?.candidate || null; + const imageUrl = activeCard?.imageUrl || ''; + + const handleBack = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + + navigation.navigate('Camera' as never); + }, [navigation]); + + return ( + + + + + {candidate ? ( + { + setImageReady(true); + setImageFailed(false); + }} + onError={handleImageLoadError} + /> + ) : ( + + )} + + + + {candidate && ( + <> + + + Hold to rate urgency + + + + + + + ← + + + Swipe left + + {t('sortscreen.spam') || 'Spam'} + + Low signal or junk + + + + + + → + + + Swipe right + + {t('sortscreen.highValue') || 'High Value'} + + Worth escalating + + + + + {t('sortscreen.urgency') || 'Urgency'} + + + + + 10 + + 0 + + + + + {urgencyScore} + + + + + + + Report #{candidate.report.seq} + + + + )} + + {!imageReady && !imageFailed && candidate && !isLoading && ( + + + + )} + + {imageFailed && candidate && !isLoading && ( + + + {t('sortscreen.problem') || 'Something went wrong'} + + + {t('sortscreen.imageUnavailable') || + 'Image unavailable for this report.'} + + + )} + + {isLoading && !candidate && ( + + + + {t('sortscreen.waiting') || 'Loading your next report...'} + + + )} + + {isSubmitting && ( + + + + {t('sortscreen.submitting') || 'Submitting your sort...'} + + + )} + + {emptyState && !isLoading && ( + + + {t('sortscreen.emptyTitle') || 'Queue complete'} + + + {t('sortscreen.emptyBody') || + 'You have already sorted every report currently available to you.'} + + { + void loadFreshCandidate(); + }}> + + {t('sortscreen.retry') || 'Check again'} + + + + )} + + {!!errorMessage && !isLoading && ( + + + {t('sortscreen.problem') || 'Something went wrong'} + + {errorMessage} + { + void loadFreshCandidate(); + }}> + + {t('sortscreen.retry') || 'Retry'} + + + + )} + + + + + + {t('sortscreen.back') || 'Back'} + + + + + {sessionKitns} + + {t('sortscreen.kitn') || 'KITN'} + + + + + + + ); +}; + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: '#06100A', + }, + container: { + flex: 1, + backgroundColor: '#06100A', + paddingHorizontal: 10, + paddingTop: 8, + paddingBottom: 10, + }, + stage: { + flex: 1, + borderRadius: 32, + overflow: 'hidden', + backgroundColor: '#050806', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.06)', + }, + surface: { + flex: 1, + backgroundColor: '#050806', + }, + reportImage: { + width: '100%', + height: '100%', + }, + reportFallback: { + ...StyleSheet.absoluteFillObject, + backgroundColor: '#08110C', + }, + topBar: { + position: 'absolute', + top: 14, + left: 14, + right: 14, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + backButton: { + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 999, + backgroundColor: 'rgba(5, 8, 6, 0.68)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + }, + backButtonText: { + color: theme.COLORS.TEXT_WHITE, + fontFamily: fontFamilies.DefaultBold, + fontSize: 14, + }, + kitnBadge: { + flexDirection: 'row', + alignItems: 'baseline', + gap: 6, + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 999, + backgroundColor: 'rgba(37, 192, 136, 0.16)', + borderWidth: 1, + borderColor: 'rgba(89, 228, 128, 0.34)', + }, + kitnValue: { + color: '#8AF5A7', + fontFamily: fontFamilies.DefaultBold, + fontSize: 18, + }, + kitnLabel: { + color: theme.COLORS.TEXT_WHITE, + fontFamily: fontFamilies.Default, + fontSize: 12, + opacity: 0.84, + }, + centerHintPill: { + position: 'absolute', + top: 74, + alignSelf: 'center', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 999, + backgroundColor: 'rgba(5, 8, 6, 0.56)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.10)', + }, + centerHintText: { + color: theme.COLORS.TEXT_WHITE, + fontFamily: fontFamilies.DefaultBold, + fontSize: 13, + letterSpacing: 0.3, + }, + sideCue: { + position: 'absolute', + top: '31%', + width: 144, + minHeight: 112, + paddingHorizontal: 16, + paddingTop: 22, + paddingBottom: 16, + borderRadius: 26, + backgroundColor: 'rgba(4, 7, 5, 0.78)', + borderWidth: 1.4, + justifyContent: 'flex-end', + shadowColor: '#000', + shadowOffset: {width: 0, height: 12}, + shadowOpacity: 0.24, + shadowRadius: 24, + elevation: 9, + }, + sideCueLeft: { + left: 14, + borderColor: 'rgba(255, 138, 90, 0.78)', + }, + sideCueRight: { + right: 14, + borderColor: 'rgba(129, 243, 163, 0.78)', + }, + sideCueArrowBadge: { + position: 'absolute', + top: 12, + width: 48, + height: 48, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + }, + sideCueArrowBadgeLeft: { + left: 14, + backgroundColor: 'rgba(255, 124, 78, 0.18)', + borderColor: 'rgba(255, 157, 118, 0.42)', + }, + sideCueArrowBadgeRight: { + right: 14, + backgroundColor: 'rgba(111, 242, 155, 0.18)', + borderColor: 'rgba(149, 244, 176, 0.42)', + }, + sideCueArrow: { + fontSize: 30, + fontFamily: fontFamilies.DefaultBold, + color: theme.COLORS.TEXT_WHITE, + textShadowColor: 'rgba(0,0,0,0.42)', + textShadowOffset: {width: 0, height: 4}, + textShadowRadius: 12, + }, + sideCueArrowLeft: { + color: '#FF9D76', + }, + sideCueArrowRight: { + color: '#95F4B0', + }, + sideCueDirection: { + color: 'rgba(255,255,255,0.74)', + fontFamily: fontFamilies.DefaultBold, + fontSize: 11, + letterSpacing: 1, + textTransform: 'uppercase', + marginBottom: 6, + }, + sideCueLabel: { + color: theme.COLORS.TEXT_WHITE, + fontFamily: fontFamilies.DefaultBold, + fontSize: 18, + textShadowColor: 'rgba(0,0,0,0.34)', + textShadowOffset: {width: 0, height: 2}, + textShadowRadius: 8, + }, + sideCueMeta: { + color: theme.COLORS.TEXT_WHITE, + fontFamily: fontFamilies.Default, + fontSize: 12, + opacity: 0.92, + marginTop: 4, + textShadowColor: 'rgba(0,0,0,0.28)', + textShadowOffset: {width: 0, height: 2}, + textShadowRadius: 6, + }, + thermometerDock: { + position: 'absolute', + left: 14, + bottom: 24, + alignItems: 'center', + gap: 8, + }, + thermometerTitle: { + color: theme.COLORS.TEXT_WHITE, + fontFamily: fontFamilies.DefaultBold, + fontSize: 12, + letterSpacing: 1.1, + textTransform: 'uppercase', + }, + thermometerWrap: { + width: 46, + height: THERMOMETER_HEIGHT, + borderRadius: 24, + backgroundColor: 'rgba(5, 8, 6, 0.62)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + overflow: 'hidden', + justifyContent: 'space-between', + }, + thermometerFill: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + backgroundColor: '#25C088', + }, + thermometerScale: { + flex: 1, + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 10, + }, + thermometerScaleText: { + color: theme.COLORS.TEXT_WHITE, + fontFamily: fontFamilies.DefaultBold, + fontSize: 10, + opacity: 0.84, + }, + thermometerDivider: { + flex: 1, + }, + thermometerScorePill: { + minWidth: 38, + paddingHorizontal: 10, + paddingVertical: 7, + borderRadius: 999, + backgroundColor: 'rgba(5, 8, 6, 0.72)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + alignItems: 'center', + }, + thermometerScoreValue: { + color: theme.COLORS.TEXT_WHITE, + fontFamily: fontFamilies.DefaultBold, + fontSize: 16, + }, + reportChip: { + position: 'absolute', + left: 14, + right: 14, + bottom: 18, + alignItems: 'center', + }, + reportChipText: { + color: theme.COLORS.TEXT_WHITE, + fontFamily: fontFamilies.DefaultBold, + fontSize: 13, + paddingHorizontal: 14, + paddingVertical: 9, + borderRadius: 999, + backgroundColor: 'rgba(5, 8, 6, 0.62)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.10)', + overflow: 'hidden', + }, + imageStateOverlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(5, 8, 6, 0.36)', + }, + statusDock: { + position: 'absolute', + left: 20, + right: 20, + bottom: 34, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 10, + paddingHorizontal: 18, + paddingVertical: 16, + borderRadius: 24, + backgroundColor: 'rgba(5, 8, 6, 0.82)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.10)', + }, + statusDockText: { + color: theme.COLORS.TEXT_WHITE, + fontFamily: fontFamilies.Default, + fontSize: 15, + }, + centerPanel: { + position: 'absolute', + left: 20, + right: 20, + bottom: 34, + paddingHorizontal: 18, + paddingVertical: 18, + borderRadius: 24, + backgroundColor: 'rgba(5, 8, 6, 0.86)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.10)', + alignItems: 'center', + gap: 10, + }, + panelTitle: { + color: theme.COLORS.TEXT_WHITE, + fontFamily: fontFamilies.DefaultBold, + fontSize: 22, + textAlign: 'center', + }, + panelBody: { + color: theme.COLORS.TEXT_GREY, + fontFamily: fontFamilies.Default, + fontSize: 15, + lineHeight: 22, + textAlign: 'center', + }, + panelButton: { + marginTop: 2, + paddingHorizontal: 16, + paddingVertical: 11, + borderRadius: 999, + backgroundColor: '#59E480', + }, + panelButtonText: { + color: '#06100A', + fontFamily: fontFamilies.DefaultBold, + fontSize: 14, + }, +}); + +export default SortScreen; diff --git a/src/services/API/APIManager.js b/src/services/API/APIManager.js index 5a6cfac..d9c7b3a 100644 --- a/src/services/API/APIManager.js +++ b/src/services/API/APIManager.js @@ -1,74 +1,71 @@ -import { getJSONData, postJSONData } from './CoreAPICalls'; -import { settings as s, getUrls } from './Settings'; -import { - getMapLocation, - getOrCreatePushInstallID, -} from '../DataManager'; +import {getJSONData, postJSONData} from './CoreAPICalls'; +import {settings as s, getUrls} from './Settings'; +import {getMapLocation, getOrCreatePushInstallID} from '../DataManager'; import MatchReportsLogger from '../../utils/MatchReportsLogger'; import AppVersionService from '../AppVersionService'; - -// === API v.2 - -export const updateOrCreateUser = async ( - publicAddress, - avatar, - refKey, - referral, -) => { - try { - const data = { - version: '2.0', - id: publicAddress, - avatar: avatar, - ref_key: refKey, - referral: referral, - }; - const response = await postJSONData(s.v2api.updateOrCreateUser, data); - const ret = { - ok: response.ok, - }; - if (response.ok) { - resp_json = await response.json(); - ret.team = resp_json.team; - ret.dup_avatar = resp_json.dup_avatar; - } else { - if (response.error) { - ret.error = response.error; - } else if (response.status) { - ret.error = response.statusText; - } - } - return ret; - } catch (err) { - return null; - } -}; - -export const updatePrivacyAndTOC = async (publicAddress, privacy, agreeTOC) => { - try { - const data = { - version: '2.0', - id: publicAddress, - privacy: privacy, - agree_toc: agreeTOC || '', - }; - const response = await postJSONData(s.v2api.updatePrivacyAndToc, data); - const ret = { - ok: response.ok, - }; - if (!response.ok) { - if (response.error) { - ret.error = response.error; - } else if (response.status) { - ret.error = response.statusText; - } - } - return ret; - } catch (err) { - return null; - } -}; - + +// === API v.2 + +export const updateOrCreateUser = async ( + publicAddress, + avatar, + refKey, + referral, +) => { + try { + const data = { + version: '2.0', + id: publicAddress, + avatar: avatar, + ref_key: refKey, + referral: referral, + }; + const response = await postJSONData(s.v2api.updateOrCreateUser, data); + const ret = { + ok: response.ok, + }; + if (response.ok) { + const responseJson = await response.json(); + ret.team = responseJson.team; + ret.dup_avatar = responseJson.dup_avatar; + } else { + if (response.error) { + ret.error = response.error; + } else if (response.status) { + ret.error = response.statusText; + } + } + return ret; + } catch (err) { + return null; + } +}; + +export const updatePrivacyAndTOC = async (publicAddress, privacy, agreeTOC) => { + try { + const data = { + version: '2.0', + id: publicAddress, + privacy: privacy, + agree_toc: agreeTOC || '', + }; + const response = await postJSONData(s.v2api.updatePrivacyAndToc, data); + const ret = { + ok: response.ok, + }; + if (!response.ok) { + if (response.error) { + ret.error = response.error; + } else if (response.status) { + ret.error = response.statusText; + } + } + return ret; + } catch (err) { + return null; + } +}; + export const report = async ( publicAddress, latitude, @@ -92,20 +89,23 @@ export const report = async ( x: relX, y: relY, image: image, - }; - - // Add annotation if provided - if (annotation && annotation.trim()) { - data.annotation = annotation.trim(); - } - - const response = await fetch(`${getUrls().liveUrl}/api/v1/human-reports/submit`, { - method: 'post', - headers: { - 'Content-Type': 'application/json', + }; + + // Add annotation if provided + if (annotation && annotation.trim()) { + data.annotation = annotation.trim(); + } + + const response = await fetch( + `${getUrls().liveUrl}/api/v1/human-reports/submit`, + { + method: 'post', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), }, - body: JSON.stringify(data), - }); + ); const ret = { ok: response.ok, }; @@ -126,534 +126,534 @@ export const report = async ( } catch (err) { return null; } -}; - -export const getReportsOnMap = async ( - publicAddress, - latMin, - lonMin, - latMax, - lonMax, - latCenter, - lonCenter, -) => { - try { - const data = { - version: '2.0', - id: publicAddress, - vport: { - latmin: latMin, - lonmin: lonMin, - latmax: latMax, - lonmax: lonMax, - }, - center: { - lat: latCenter, - lon: lonCenter, - }, - }; - const response = await postJSONData(s.v2api.getMap, data); - const ret = { - ok: response.ok, - }; - if (response.ok) { - ret.reports = await response.json().then(reports => { - return reports; - }); - } else { - if (response.error) { - ret.error = response.error; - } else if (response.status) { - ret.error = response.statusText; - } - } - return ret; - } catch (err) { - return null; - } -}; - -export const readReport = async (publicAddress, reportSeq) => { - try { - const data = { - version: '2.0', - id: publicAddress, - seq: reportSeq, - }; - const response = await postJSONData(s.v2api.readReport, data); - const ret = { - ok: response.ok, - }; - if (response.ok) { - ret.report = await response.json().then(reports => { - return reports; - }); - } else { - if (response.error) { - ret.error = response.error; - } else if (response.status) { - ret.error = response.statusText; - } - } - return ret; - } catch (err) { - return null; - } -}; - -export const fetchReferral = async key => { - try { - const data = { - version: '2.0', - refkey: key, - }; - const response = await postJSONData(s.v2api.readReferral, data); - const ret = { - ok: response.ok, - }; - if (response.ok) { - ret.refid = await response.json().then(response => response.refvalue); - } else { - if (response.error) { - ret.error = response.error; - } else if (response.status) { - ret.error = response.statusText; - } - } - return ret; - } catch (err) { - return null; - } -}; - -export const generateReferral = async publicAddress => { - try { - const data = { - version: '2.0', - id: publicAddress, - }; - const response = await postJSONData(s.v2api.generateReferral, data); - const ret = { - ok: response.ok, - }; - if (response.ok) { - ret.refid = await response.json().then(response => response.refvalue); - } else { - if (response.error) { - ret.error = response.error; - } else if (response.status) { - ret.error = response.statusText; - } - } - return ret; - } catch (err) { - return null; - } -}; - -export const getTeams = async publicAddress => { - try { - const data = { - version: '2.0', - id: publicAddress, - }; - const response = await postJSONData(s.v2api.getTeams, data); - const ret = { - ok: response.ok, - }; - if (response.ok) { - respJson = await response.json(); - ret.green = respJson.green; - ret.blue = respJson.blue; - } else { - if (response.error) { - ret.error = response.error; - } else if (response.status) { - ret.error = response.statusText; - } - } - return ret; - } catch (err) { - return null; - } -}; - -export const getTopScores = async publicAddress => { - try { - const data = { - version: '2.0', - id: publicAddress, - }; - const response = await postJSONData(s.v2api.getTopScores, data); - const ret = { - ok: response.ok, - }; - if (response.ok) { - ret.records = await response.json().then(response => response.records); - } else { - if (response.error) { - ret.error = response.error; - } else if (response.status) { - ret.error = response.statusText; - } - } - return ret; - } catch (err) { - return null; - } -}; - -export const getRewardStats = async publicAddress => { - try { - const data = { - version: '2.0', - id: publicAddress, - }; - const response = await postJSONData(s.v2api.getStats, data); - const ret = { - ok: response.ok, - }; - if (response.ok) { - ret.stats = await response.json().then(response => response); - } else { - if (response.error) { - ret.error = response.error; - } else if (response.status) { - ret.error = response.statusText; - } - } - return ret; - } catch (err) { - return null; - } -}; - -export const getBlockchainLink = async publicAddress => { - try { - const data = { - version: '2.0', - id: publicAddress, - }; - const response = await postJSONData(s.v2api.getBlockchainLink, data); - const ret = { - ok: response.ok, - }; - if (response.ok) { - respJson = await response.json(); - ret.blockchainLink = respJson.blockchain_link; - } else { - if (response.error) { - ret.error = response.error; - } else if (response.status) { - ret.error = response.statusText; - } - } - return ret; - } catch (err) { - return null; - } -}; - -export const createOrUpdateArea = async area => { - try { - const data = { - version: '2.0', - area: area, - }; - const response = await postJSONData(s.v2api.createOrUpdateArea, data); - const ret = { - ok: response.ok, - }; - if (!response.ok) { - if (response.error) { - ret.error = response.error; - } else if (response.status) { - ret.error = response.statusText; - } - } - } catch (err) { - return null; - } -}; - -export const getAreas = async (latMin, lonMin, latMax, lonMax) => { - try { - const params = { - sw_lat: `${latMin}`, - sw_lon: `${lonMin}`, - ne_lat: `${latMax}`, - ne_lon: `${lonMax}`, - }; - const response = await getJSONData(s.v2api.getArea, params); - const ret = { - ok: response.ok, - }; - if (response.ok) { - ret.areas = await response.json().then(response => response); - } else { - if (response.error) { - ret.error = response.error; - } else if (response.status) { - ret.error = response.statusText; - } - } - return ret; - } catch (err) { - console.error(err); - return null; - } -}; - -export const getReportsByLatLon = async (lat, lon) => { - try { - const params = { - latitude: `${lat}`, - longitude: `${lon}`, - radius_km: '0.5', - n: '10', - lang: 'en', - }; - - // const url = `${s.v3api.getReportsByLatLon}?${new URLSearchParams(params).toString()}`; - const url = `${getUrls().liveUrl}/api/v3/reports/by-latlng-lite?latitude=${lat}&longitude=${lon}&radius_km=0.5&n=10&lang='en'`; - console.log('URL', url); - const response = await fetch(url); - console.log('Response', response); - const ret = { - ok: response.ok, - reports: undefined, - error: undefined, - }; - if (response.ok) { - ret.reports = await response.json().then(response => response); - } else { - if (response.error) { - ret.error = response.error; - } else if (response.status) { - ret.error = response.statusText; - } - } - - console.log('Ret', ret); - return ret; - } catch (err) { - console.error(err); - return null; - } -}; - +}; + +export const getReportsOnMap = async ( + publicAddress, + latMin, + lonMin, + latMax, + lonMax, + latCenter, + lonCenter, +) => { + try { + const data = { + version: '2.0', + id: publicAddress, + vport: { + latmin: latMin, + lonmin: lonMin, + latmax: latMax, + lonmax: lonMax, + }, + center: { + lat: latCenter, + lon: lonCenter, + }, + }; + const response = await postJSONData(s.v2api.getMap, data); + const ret = { + ok: response.ok, + }; + if (response.ok) { + ret.reports = await response.json().then(reports => { + return reports; + }); + } else { + if (response.error) { + ret.error = response.error; + } else if (response.status) { + ret.error = response.statusText; + } + } + return ret; + } catch (err) { + return null; + } +}; + +export const readReport = async (publicAddress, reportSeq) => { + try { + const data = { + version: '2.0', + id: publicAddress, + seq: reportSeq, + }; + const response = await postJSONData(s.v2api.readReport, data); + const ret = { + ok: response.ok, + }; + if (response.ok) { + ret.report = await response.json().then(reports => { + return reports; + }); + } else { + if (response.error) { + ret.error = response.error; + } else if (response.status) { + ret.error = response.statusText; + } + } + return ret; + } catch (err) { + return null; + } +}; + +export const fetchReferral = async key => { + try { + const data = { + version: '2.0', + refkey: key, + }; + const response = await postJSONData(s.v2api.readReferral, data); + const ret = { + ok: response.ok, + }; + if (response.ok) { + ret.refid = await response.json().then(response => response.refvalue); + } else { + if (response.error) { + ret.error = response.error; + } else if (response.status) { + ret.error = response.statusText; + } + } + return ret; + } catch (err) { + return null; + } +}; + +export const generateReferral = async publicAddress => { + try { + const data = { + version: '2.0', + id: publicAddress, + }; + const response = await postJSONData(s.v2api.generateReferral, data); + const ret = { + ok: response.ok, + }; + if (response.ok) { + ret.refid = await response.json().then(response => response.refvalue); + } else { + if (response.error) { + ret.error = response.error; + } else if (response.status) { + ret.error = response.statusText; + } + } + return ret; + } catch (err) { + return null; + } +}; + +export const getTeams = async publicAddress => { + try { + const data = { + version: '2.0', + id: publicAddress, + }; + const response = await postJSONData(s.v2api.getTeams, data); + const ret = { + ok: response.ok, + }; + if (response.ok) { + const responseJson = await response.json(); + ret.green = responseJson.green; + ret.blue = responseJson.blue; + } else { + if (response.error) { + ret.error = response.error; + } else if (response.status) { + ret.error = response.statusText; + } + } + return ret; + } catch (err) { + return null; + } +}; + +export const getTopScores = async publicAddress => { + try { + const data = { + version: '2.0', + id: publicAddress, + }; + const response = await postJSONData(s.v2api.getTopScores, data); + const ret = { + ok: response.ok, + }; + if (response.ok) { + ret.records = await response.json().then(response => response.records); + } else { + if (response.error) { + ret.error = response.error; + } else if (response.status) { + ret.error = response.statusText; + } + } + return ret; + } catch (err) { + return null; + } +}; + +export const getRewardStats = async publicAddress => { + try { + const data = { + version: '2.0', + id: publicAddress, + }; + const response = await postJSONData(s.v2api.getStats, data); + const ret = { + ok: response.ok, + }; + if (response.ok) { + ret.stats = await response.json().then(response => response); + } else { + if (response.error) { + ret.error = response.error; + } else if (response.status) { + ret.error = response.statusText; + } + } + return ret; + } catch (err) { + return null; + } +}; + +export const getBlockchainLink = async publicAddress => { + try { + const data = { + version: '2.0', + id: publicAddress, + }; + const response = await postJSONData(s.v2api.getBlockchainLink, data); + const ret = { + ok: response.ok, + }; + if (response.ok) { + const responseJson = await response.json(); + ret.blockchainLink = responseJson.blockchain_link; + } else { + if (response.error) { + ret.error = response.error; + } else if (response.status) { + ret.error = response.statusText; + } + } + return ret; + } catch (err) { + return null; + } +}; + +export const createOrUpdateArea = async area => { + try { + const data = { + version: '2.0', + area: area, + }; + const response = await postJSONData(s.v2api.createOrUpdateArea, data); + const ret = { + ok: response.ok, + }; + if (!response.ok) { + if (response.error) { + ret.error = response.error; + } else if (response.status) { + ret.error = response.statusText; + } + } + } catch (err) { + return null; + } +}; + +export const getAreas = async (latMin, lonMin, latMax, lonMax) => { + try { + const params = { + sw_lat: `${latMin}`, + sw_lon: `${lonMin}`, + ne_lat: `${latMax}`, + ne_lon: `${lonMax}`, + }; + const response = await getJSONData(s.v2api.getArea, params); + const ret = { + ok: response.ok, + }; + if (response.ok) { + ret.areas = await response.json().then(response => response); + } else { + if (response.error) { + ret.error = response.error; + } else if (response.status) { + ret.error = response.statusText; + } + } + return ret; + } catch (err) { + console.error(err); + return null; + } +}; + +export const getReportsByLatLon = async (lat, lon) => { + try { + const params = { + latitude: `${lat}`, + longitude: `${lon}`, + radius_km: '0.5', + n: '10', + lang: 'en', + }; + + // const url = `${s.v3api.getReportsByLatLon}?${new URLSearchParams(params).toString()}`; + const url = `${getUrls().liveUrl}/api/v3/reports/by-latlng-lite?latitude=${lat}&longitude=${lon}&radius_km=0.5&n=10&lang='en'`; + console.log('URL', url); + const response = await fetch(url); + console.log('Response', response); + const ret = { + ok: response.ok, + reports: undefined, + error: undefined, + }; + if (response.ok) { + ret.reports = await response.json().then(response => response); + } else { + if (response.error) { + ret.error = response.error; + } else if (response.status) { + ret.error = response.statusText; + } + } + + console.log('Ret', ret); + return ret; + } catch (err) { + console.error(err); + return null; + } +}; + export const getReportsById = async (userId, limit = 100) => { try { const boundedLimit = Math.max(1, Math.min(Number(limit) || 100, 250)); const url = `${getUrls().liveUrl}/api/v3/reports/by-id?id=${encodeURIComponent( userId, )}&n=${boundedLimit}`; - - const response = await fetch(url); - - const ret = { - ok: response.ok, - reports: undefined, - error: undefined, - }; - - if (response.ok) { - ret.reports = await response.json().then(data => data); - } else { - if (response.error) { - ret.error = response.error; - } else if (response.status) { - ret.error = response.statusText; - } - } - - return ret; - } catch (err) { - console.error('Error fetching reports by ID:', err); - return { - ok: false, - error: err.message || 'Unknown error occurred', - reports: undefined, - }; - } -}; - -export const matchReports = async ( - publicAddress, - latitude, - longitude, - image, -) => { - const startTime = Date.now(); - const processId = `match_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - - try { - // Log process start - MatchReportsLogger.logProcessStart({ - processId, - publicAddress, - latitude, - longitude, - imageSize: image ? image.length : 0, - }); - - // Validate input data - const validationErrors = []; - if (!publicAddress) validationErrors.push('publicAddress is required'); - if (!latitude || typeof latitude !== 'number') - validationErrors.push('latitude must be a valid number'); - if (!longitude || typeof longitude !== 'number') - validationErrors.push('longitude must be a valid number'); - if (!image || typeof image !== 'string') - validationErrors.push('image must be a valid base64 string'); - - MatchReportsLogger.logDataValidation( - { - publicAddress, - latitude, - longitude, - image, - }, - validationErrors, - ); - - if (validationErrors.length > 0) { - throw new Error(`Validation failed: ${validationErrors.join(', ')}`); - } - - // Prepare request data - const data = { - version: '2.0', - id: publicAddress, - latitude: latitude, - longitude: longitude, - x: 0.5, - y: 0.5, - image: image, - }; - - // Log API request - MatchReportsLogger.logApiRequest(data, s.v3api.matchReport); - - const apiStartTime = Date.now(); - const config = { - method: 'post', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }; - const response = await fetch( - `${getUrls().processingUrl}/api/v3/match_report`, - config, - ); - const apiDuration = Date.now() - apiStartTime; - - // Log API response - MatchReportsLogger.logApiResponse( - response, - s.v3api.matchReport, - apiDuration, - ); - - const ret = { - ok: response.ok, - processId, - }; - - if (response.ok) { - const responseData = await response.json(); - ret.success = responseData.success; - ret.message = responseData.message; - ret.results = responseData.results || []; - - // Log process success - const totalDuration = Date.now() - startTime; - MatchReportsLogger.logProcessSuccess(ret, totalDuration); - - // Log performance metrics - MatchReportsLogger.logPerformanceMetrics({ - totalDuration, - apiCallDuration: apiDuration, - dataProcessingDuration: totalDuration - apiDuration, - imageSize: image.length, - matchCount: ret.results.length, - }); - } else { - if (response.error) { - ret.error = response.error; - } else if (response.status) { - ret.error = response.statusText; - } - - // Log process error - const totalDuration = Date.now() - startTime; - MatchReportsLogger.logProcessError( - new Error(ret.error || 'API call failed'), - { processId, response }, - totalDuration, - ); - } - - return ret; - } catch (err) { - const totalDuration = Date.now() - startTime; - - // Log process error - MatchReportsLogger.logProcessError( - err, - { - processId, - publicAddress, - latitude, - longitude, - imageSize: image ? image.length : 0, - }, - totalDuration, - ); - - return { - ok: false, - error: err.message || 'Unknown error occurred', - processId, - }; - } -}; - -/** - * Read the email delivery status for a specific report. - * POST /read_report_email_status - * - * @param {string} userId - The current user's wallet address/ID (report owner) - * @param {number} seq - The report sequence number returned from report submission - * @returns {{ - * seq: number, - * status: 'pending'|'pending_retry'|'processed_no_delivery'|'sent', - * last_email_sent_at?: string, - * next_attempt_at?: string, - * retry_reason?: string, - * recipient_count: number, - * recipients?: Array<{ email: string, delivery_source: string, delivery_status: string, sent_at: string }> - * }|null} - */ + + const response = await fetch(url); + + const ret = { + ok: response.ok, + reports: undefined, + error: undefined, + }; + + if (response.ok) { + ret.reports = await response.json().then(data => data); + } else { + if (response.error) { + ret.error = response.error; + } else if (response.status) { + ret.error = response.statusText; + } + } + + return ret; + } catch (err) { + console.error('Error fetching reports by ID:', err); + return { + ok: false, + error: err.message || 'Unknown error occurred', + reports: undefined, + }; + } +}; + +export const matchReports = async ( + publicAddress, + latitude, + longitude, + image, +) => { + const startTime = Date.now(); + const processId = `match_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + try { + // Log process start + MatchReportsLogger.logProcessStart({ + processId, + publicAddress, + latitude, + longitude, + imageSize: image ? image.length : 0, + }); + + // Validate input data + const validationErrors = []; + if (!publicAddress) validationErrors.push('publicAddress is required'); + if (!latitude || typeof latitude !== 'number') + validationErrors.push('latitude must be a valid number'); + if (!longitude || typeof longitude !== 'number') + validationErrors.push('longitude must be a valid number'); + if (!image || typeof image !== 'string') + validationErrors.push('image must be a valid base64 string'); + + MatchReportsLogger.logDataValidation( + { + publicAddress, + latitude, + longitude, + image, + }, + validationErrors, + ); + + if (validationErrors.length > 0) { + throw new Error(`Validation failed: ${validationErrors.join(', ')}`); + } + + // Prepare request data + const data = { + version: '2.0', + id: publicAddress, + latitude: latitude, + longitude: longitude, + x: 0.5, + y: 0.5, + image: image, + }; + + // Log API request + MatchReportsLogger.logApiRequest(data, s.v3api.matchReport); + + const apiStartTime = Date.now(); + const config = { + method: 'post', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }; + const response = await fetch( + `${getUrls().processingUrl}/api/v3/match_report`, + config, + ); + const apiDuration = Date.now() - apiStartTime; + + // Log API response + MatchReportsLogger.logApiResponse( + response, + s.v3api.matchReport, + apiDuration, + ); + + const ret = { + ok: response.ok, + processId, + }; + + if (response.ok) { + const responseData = await response.json(); + ret.success = responseData.success; + ret.message = responseData.message; + ret.results = responseData.results || []; + + // Log process success + const totalDuration = Date.now() - startTime; + MatchReportsLogger.logProcessSuccess(ret, totalDuration); + + // Log performance metrics + MatchReportsLogger.logPerformanceMetrics({ + totalDuration, + apiCallDuration: apiDuration, + dataProcessingDuration: totalDuration - apiDuration, + imageSize: image.length, + matchCount: ret.results.length, + }); + } else { + if (response.error) { + ret.error = response.error; + } else if (response.status) { + ret.error = response.statusText; + } + + // Log process error + const totalDuration = Date.now() - startTime; + MatchReportsLogger.logProcessError( + new Error(ret.error || 'API call failed'), + {processId, response}, + totalDuration, + ); + } + + return ret; + } catch (err) { + const totalDuration = Date.now() - startTime; + + // Log process error + MatchReportsLogger.logProcessError( + err, + { + processId, + publicAddress, + latitude, + longitude, + imageSize: image ? image.length : 0, + }, + totalDuration, + ); + + return { + ok: false, + error: err.message || 'Unknown error occurred', + processId, + }; + } +}; + +/** + * Read the email delivery status for a specific report. + * POST /read_report_email_status + * + * @param {string} userId - The current user's wallet address/ID (report owner) + * @param {number} seq - The report sequence number returned from report submission + * @returns {{ + * seq: number, + * status: 'pending'|'pending_retry'|'processed_no_delivery'|'sent', + * last_email_sent_at?: string, + * next_attempt_at?: string, + * retry_reason?: string, + * recipient_count: number, + * recipients?: Array<{ email: string, delivery_source: string, delivery_status: string, sent_at: string }> + * }|null} + */ export const readReportEmailStatus = async (userId, seq) => { - try { - const data = { - version: '2.0', - id: userId, - seq: seq, - }; - const response = await postJSONData('read_report_email_status', data); - - if (!response.ok) { - return null; - } - - const result = await response.json(); - return result; - } catch (err) { - console.warn('readReportEmailStatus error:', err.message); - return null; + try { + const data = { + version: '2.0', + id: userId, + seq: seq, + }; + const response = await postJSONData('read_report_email_status', data); + + if (!response.ok) { + return null; + } + + const result = await response.json(); + return result; + } catch (err) { + console.warn('readReportEmailStatus error:', err.message); + return null; } }; @@ -666,20 +666,23 @@ export const registerMobilePushDevice = async ({ notificationsEnabled, }) => { try { - const response = await fetch(`${getUrls().liveUrl}/api/v3/mobile/push/register`, { - method: 'post', - headers: { - 'Content-Type': 'application/json', + const response = await fetch( + `${getUrls().liveUrl}/api/v3/mobile/push/register`, + { + method: 'post', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + install_id: installId, + platform: platform, + provider: provider, + push_token: pushToken, + app_version: appVersion, + notifications_enabled: Boolean(notificationsEnabled), + }), }, - body: JSON.stringify({ - install_id: installId, - platform: platform, - provider: provider, - push_token: pushToken, - app_version: appVersion, - notifications_enabled: Boolean(notificationsEnabled), - }), - }); + ); if (!response.ok) { return null; @@ -694,16 +697,19 @@ export const registerMobilePushDevice = async ({ export const unregisterMobilePushDevice = async ({installId, provider}) => { try { - const response = await fetch(`${getUrls().liveUrl}/api/v3/mobile/push/unregister`, { - method: 'post', - headers: { - 'Content-Type': 'application/json', + const response = await fetch( + `${getUrls().liveUrl}/api/v3/mobile/push/unregister`, + { + method: 'post', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + install_id: installId, + provider: provider, + }), }, - body: JSON.stringify({ - install_id: installId, - provider: provider, - }), - }); + ); if (!response.ok) { return null; @@ -722,9 +728,11 @@ export const unregisterMobilePushDevice = async ({installId, provider}) => { */ export const readReportCases = async seq => { try { - const url = `${getUrls().liveUrl}/api/v3/reports/cases?${new URLSearchParams({ - seq: String(seq), - })}`; + const url = `${getUrls().liveUrl}/api/v3/reports/cases?${new URLSearchParams( + { + seq: String(seq), + }, + )}`; const response = await fetch(url); if (!response.ok) { return null; @@ -738,9 +746,11 @@ export const readReportCases = async seq => { export const readDetailedReportBySeq = async seq => { try { - const url = `${getUrls().liveUrl}/api/v3/reports/by-seq?${new URLSearchParams({ - seq: String(seq), - })}`; + const url = `${getUrls().liveUrl}/api/v3/reports/by-seq?${new URLSearchParams( + { + seq: String(seq), + }, + )}`; const response = await fetch(url); if (!response.ok) { return null; @@ -754,9 +764,11 @@ export const readDetailedReportBySeq = async seq => { export const readDetailedReportByPublicId = async publicId => { try { - const url = `${getUrls().liveUrl}/api/v3/reports/by-public-id?${new URLSearchParams({ - public_id: String(publicId), - })}`; + const url = `${getUrls().liveUrl}/api/v3/reports/by-public-id?${new URLSearchParams( + { + public_id: String(publicId), + }, + )}`; const response = await fetch(url); if (!response.ok) { return null; @@ -767,3 +779,87 @@ export const readDetailedReportByPublicId = async publicId => { return null; } }; + +export const getNextSortReport = async (sorterId, excludedReportSeqs = []) => { + try { + const params = { + sorter_id: String(sorterId || ''), + }; + if (Array.isArray(excludedReportSeqs) && excludedReportSeqs.length > 0) { + params.exclude_report_seqs = excludedReportSeqs + .map(seq => Number(seq)) + .filter(seq => Number.isInteger(seq) && seq > 0) + .join(','); + } + const url = `${getUrls().liveUrl}/api/v3/${s.v3api.getNextSortReport}?${new URLSearchParams( + params, + )}`; + const response = await fetch(url); + + if (response.status === 404) { + return { + ok: false, + empty: true, + }; + } + + if (!response.ok) { + return { + ok: false, + error: response.statusText, + }; + } + + const candidate = await response.json(); + return { + ok: true, + candidate, + }; + } catch (err) { + console.warn('getNextSortReport error:', err.message); + return { + ok: false, + error: err.message || 'Unknown error occurred', + }; + } +}; + +export const submitSortReport = async ({ + sorterId, + reportSeq, + verdict, + urgencyScore, +}) => { + try { + const response = await fetch( + `${getUrls().liveUrl}/api/v3/${s.v3api.submitSortReport}`, + { + method: 'post', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + sorter_id: sorterId, + report_seq: reportSeq, + verdict: verdict, + urgency_score: urgencyScore, + }), + }, + ); + + const payload = await response.json().catch(() => null); + + return { + ok: response.ok, + status: response.status, + error: payload?.error || response.statusText, + submission: payload, + }; + } catch (err) { + console.warn('submitSortReport error:', err.message); + return { + ok: false, + error: err.message || 'Unknown error occurred', + }; + } +}; diff --git a/src/services/API/Settings.js b/src/services/API/Settings.js index 648ef0f..f446942 100644 --- a/src/services/API/Settings.js +++ b/src/services/API/Settings.js @@ -1,59 +1,64 @@ -import Config from 'react-native-config'; -import { getBlockchainLink } from './APIManager'; - -export const settings = { - prod: { // Cleanapp Google Cloud Prod - apiUrl: 'https://api.cleanapp.io', - webUrl: 'https://cleanapp.io', - mapUrl: 'https://embed.cleanapp.io', - liveUrl: 'https://live.cleanapp.io', - processingUrl: 'https://processing.cleanapp.io' - }, - dev: { // Cleanapp Google Cloud Dev - apiUrl: 'http://dev.api.cleanapp.io:8080', - webUrl: 'https://dev.cleanapp.io', - mapUrl: 'https://devembed.cleanapp.io', - liveUrl: 'https://devlive.cleanapp.io', - processingUrl: 'https://devprocessing.cleanapp.io' - }, - local: { // Cleanapp Local - // URLs need to be tweaked dependent on the local environment - apiUrl: 'http://192.168.86.125:8080', - webUrl: 'http://192.168.86.125:3000', - mapUrl: 'https://devembed.cleanapp.io', - liveUrl: 'https://devlive.cleanapp.io', - processingUrl: 'https://devprocessing.cleanapp.io' - }, - v2api: { - updateOrCreateUser: 'update_or_create_user', - updatePrivacyAndToc: 'update_privacy_and_toc', - report: 'report', - getMap: 'get_map', - readReport: 'read_report', - readReferral: 'read_referral', - generateReferral: 'generate_referral', - getTeams: 'get_teams', - getTopScores: 'get_top_scores', - getStats: 'get_stats', - getBlockchainLink: 'get_blockchain_link', - createOrUpdateArea: 'create_or_update_area', - getArea: 'get_areas', - }, - v3api: { - getReportsByLatLon: 'get_reports_by_lat_lon', - }, - apiSettings: { - sendingAttempts: 3, - } -}; - -export const getUrls = () => { - switch (Config.APP_MODE) { - case 'local': - return settings.local; - case 'dev': - return settings.dev; - case 'prod': - return settings.prod; - } -}; \ No newline at end of file +import Config from 'react-native-config'; +import {getBlockchainLink} from './APIManager'; + +export const settings = { + prod: { + // Cleanapp Google Cloud Prod + apiUrl: 'https://api.cleanapp.io', + webUrl: 'https://cleanapp.io', + mapUrl: 'https://embed.cleanapp.io', + liveUrl: 'https://live.cleanapp.io', + processingUrl: 'https://processing.cleanapp.io', + }, + dev: { + // Cleanapp Google Cloud Dev + apiUrl: 'http://dev.api.cleanapp.io:8080', + webUrl: 'https://dev.cleanapp.io', + mapUrl: 'https://devembed.cleanapp.io', + liveUrl: 'https://devlive.cleanapp.io', + processingUrl: 'https://devprocessing.cleanapp.io', + }, + local: { + // Cleanapp Local + // URLs need to be tweaked dependent on the local environment + apiUrl: 'http://192.168.86.125:8080', + webUrl: 'http://192.168.86.125:3000', + mapUrl: 'https://devembed.cleanapp.io', + liveUrl: 'https://devlive.cleanapp.io', + processingUrl: 'https://devprocessing.cleanapp.io', + }, + v2api: { + updateOrCreateUser: 'update_or_create_user', + updatePrivacyAndToc: 'update_privacy_and_toc', + report: 'report', + getMap: 'get_map', + readReport: 'read_report', + readReferral: 'read_referral', + generateReferral: 'generate_referral', + getTeams: 'get_teams', + getTopScores: 'get_top_scores', + getStats: 'get_stats', + getBlockchainLink: 'get_blockchain_link', + createOrUpdateArea: 'create_or_update_area', + getArea: 'get_areas', + }, + v3api: { + getReportsByLatLon: 'get_reports_by_lat_lon', + getNextSortReport: 'reports/sort/next', + submitSortReport: 'reports/sort/submit', + }, + apiSettings: { + sendingAttempts: 3, + }, +}; + +export const getUrls = () => { + switch (Config.APP_MODE) { + case 'local': + return settings.local; + case 'dev': + return settings.dev; + case 'prod': + return settings.prod; + } +}; diff --git a/src/utils/PromiseResolver.ts b/src/utils/PromiseResolver.ts index b7793b5..eccf987 100644 --- a/src/utils/PromiseResolver.ts +++ b/src/utils/PromiseResolver.ts @@ -1,15 +1,11 @@ -const zipObject = (keys = [], values = []) => { - return keys.reduce( - (acc, key, index) => ({ - ...acc, - [key]: values[index], - }), - {}, +export const objectPromiseAll = async < + T extends Record>, +>( + obj: T, +): Promise<{[K in keyof T]: Awaited}> => { + const resolvedEntries = await Promise.all( + Object.entries(obj).map(async ([key, promise]) => [key, await promise] as const), ); -}; -export const objectPromiseAll = async (obj: {[key: string]: Promise}) => { - const keys = Object.keys(obj); - const result = await Promise.all(Object.values(obj)); - return zipObject(keys, result); + return Object.fromEntries(resolvedEntries) as {[K in keyof T]: Awaited}; }; diff --git a/src/utils/Timeout.ts b/src/utils/Timeout.ts index 9b3d958..c994382 100644 --- a/src/utils/Timeout.ts +++ b/src/utils/Timeout.ts @@ -1,17 +1,20 @@ -import {AbortController} from 'node-abort-controller'; - export default function timeoutSignal(timeout: number) { if (!Number.isInteger(timeout)) { throw new TypeError(`Expected an integer, got ${typeof timeout}`); } - const signalMap = new WeakMap(); - const controller = new AbortController(); + const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); }, timeout); - signalMap.set(controller.signal, timeoutId); - // any is needed due to some type incompatibility - return controller.signal as any; + controller.signal.addEventListener( + 'abort', + () => { + clearTimeout(timeoutId); + }, + {once: true}, + ); + + return controller.signal; } diff --git a/version.json b/version.json index 551c8e5..8f6b333 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { - "version": "3.2.28", - "buildNumber": 48, + "version": "3.2.29", + "buildNumber": 50, "versionCode": 59, "description": "CleanApp version configuration - single source of truth for all platforms" }