Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
4cabf36
Fetch flows from server for ActivityListScreen
divbzero Jan 2, 2026
d6fc84e
Fetch flows from server for useStartEntity
divbzero Jan 2, 2026
8c5bcdd
NFC: yarn lint:fix
divbzero Jan 2, 2026
f722e44
NFC: Migrate {npm-run-all => yarn}
divbzero Jan 2, 2026
6aef043
Do not normalize targetSubjectId to null for self-reports
divbzero Jan 10, 2026
2b9f89f
feat: Add includeInProgress parameter to completions API
sricharan-varanasi Jan 15, 2026
245ead2
feat: Enable fetching in-progress flows in ProgressDataCollector
sricharan-varanasi Jan 15, 2026
93c6da2
feat: Add refresh on focus to ActivityListScreen
sricharan-varanasi Jan 15, 2026
04c4ac4
feat: Add refresh on mount to InProgressActivityScreen
sricharan-varanasi Jan 15, 2026
a0e52fa
test: Add test for includeInProgress parameter in ProgressDataCollector
sricharan-varanasi Jan 15, 2026
ce5eca0
Revert "Do not normalize targetSubjectId to null for self-reports"
sricharan-varanasi Jan 15, 2026
0fbef8b
fix: Handle nullable localEndDate/localEndTime for in-progress flows
sricharan-varanasi Jan 15, 2026
72fbe47
feat: add activityFlowOrder to CompletedEntityDto
sricharan-varanasi Jan 17, 2026
7bc0cf0
feat: extend UpsertEntityProgressionPayload for in-progress flows
sricharan-varanasi Jan 17, 2026
e5db829
feat: add getFlowDetailsForInProgress helper method
sricharan-varanasi Jan 17, 2026
5c8021f
feat: update upsertEntityProgression to handle in-progress flows
sricharan-varanasi Jan 17, 2026
4f001f4
feat: implement conflict resolution in Redux reducer
sricharan-varanasi Jan 17, 2026
12f7a38
fix: handle null dates and undefined pipeline for cross-device flow r…
sricharan-varanasi Jan 21, 2026
b773319
fix: reconstruct FlowState from server data for cross-device resume
sricharan-varanasi Jan 21, 2026
f91f640
fix: sync with server on resume
sricharan-varanasi Jan 21, 2026
5cf940f
fix: check if flow with submit id is already completed
sricharan-varanasi Jan 23, 2026
b3eb73e
chore: remove debug logging statements
sricharan-varanasi Jan 26, 2026
b5aed4e
test: add coverage for cross-device flow sync functionality
sricharan-varanasi Jan 27, 2026
aa19cff
refactor: use backend timestamps instead of calculating from date/tim…
sricharan-varanasi Jan 27, 2026
6a2f669
Fix auto-completion issues
sricharan-varanasi Jan 27, 2026
c2e03aa
fix: auto-completion for inprogress
sricharan-varanasi Jan 28, 2026
ca648e8
feat: Add ITargetedProgressSyncService interface for targeted sync
sricharan-varanasi Feb 2, 2026
460e958
feat: Add TargetedProgressSyncService for single-applet sync
sricharan-varanasi Feb 2, 2026
e143432
feat: Add useTargetedSync hook for React components
sricharan-varanasi Feb 2, 2026
ef6946a
feat: Replace full refresh with targeted sync in ActivityListScreen
sricharan-varanasi Feb 2, 2026
7650811
feat: Replace full refresh with targeted sync in InProgressActivitySc…
sricharan-varanasi Feb 2, 2026
a0a09bd
Fix: In-progress flows vanishing after completion and pull-to-refresh
sricharan-varanasi Feb 5, 2026
bf45880
fix: normalize targetSubjectId for self-reports
sricharan-varanasi Feb 5, 2026
9db8ab9
fix: Allow resuming in-progress flows with one-time completion
sricharan-varanasi Feb 9, 2026
538640f
fix: sync logic for flow restart/resume
sricharan-varanasi Feb 10, 2026
3ac3819
refactor: remove submitId checks to align with web
sricharan-varanasi Feb 11, 2026
6822450
refactor: add submitId logic to ProgressSyncService
sricharan-varanasi Feb 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"please_wait": "Please wait",
"activity_all_items_hidden": "\"{{entityName}}\" cannot be started because all items inside the activity are hidden",
"flow_all_items_hidden": "\"{{entityName}}\" cannot be started because it contains an activity in which all items are hidden",
"flow_completed": "Flow Completed",
"flow_completed_elsewhere_message": "This flow was completed on another device. Refreshing to sync the latest data.",
"progress": {
"upload_files": "Uploading your files...",
"encrypt_answers": "Encrypting your answers...",
Expand Down
8 changes: 8 additions & 0 deletions src/app/ui/AppProvider/ReduxProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
blacklist: ['banners'],
};

const rootReducer = (state: any, action: AnyAction) => {

Check warning on line 34 in src/app/ui/AppProvider/ReduxProvider.tsx

View workflow job for this annotation

GitHub Actions / ESLint

src/app/ui/AppProvider/ReduxProvider.tsx#L34

Unexpected any. Specify a different type (@typescript-eslint/no-explicit-any)
if (migrateReduxStore.match(action)) {
state = action.payload;
}
Expand All @@ -44,7 +44,7 @@
defaultBanners: defaultBannersReducer,
});

return reducer(state, action);

Check warning on line 47 in src/app/ui/AppProvider/ReduxProvider.tsx

View workflow job for this annotation

GitHub Actions / ESLint

src/app/ui/AppProvider/ReduxProvider.tsx#L47

Unsafe argument of type `any` assigned to a parameter of type `{ identity: InitialState; applets: InitialState; streaming: StreamingState; banners: InitialState; defaultBanners: InitialState; } | Partial<...> | undefined` (@typescript-eslint/no-unsafe-argument)
};

const persistedReducer = persistReducer(
Expand All @@ -54,6 +54,14 @@

export const reduxStore = configureStore({
reducer: persistedReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: {
// Ignore redux-persist actions (they contain non-serializable functions)
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
ignoredPaths: ['register'],
},
}),
enhancers: getDefaultEnhancers => {
const enhancers = getDefaultEnhancers();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,13 @@ describe('AnswersUploadService', () => {
activityId: 'activity123',
appletId: 'applet123',
flowId: 'flow123',
answer: {
startTime: 1000,
endTime: 2000,
localEndDate: '2023-01-01',
localEndTime: '12:00:00',
tzOffset: -300,
},
} as ActivityAnswersRequest;

const mockCheckIfAnswersUploaded = jest.fn().mockResolvedValueOnce(false);
Expand Down Expand Up @@ -380,6 +387,13 @@ describe('AnswersUploadService', () => {
activityId: 'activity123',
appletId: 'applet123',
flowId: 'flow123',
answer: {
startTime: 1000,
endTime: 2000,
localEndDate: '2023-01-01',
localEndTime: '12:00:00',
tzOffset: -300,
},
} as ActivityAnswersRequest;

const mockCheckIfAnswersUploaded = jest.fn().mockResolvedValueOnce(false);
Expand Down
14 changes: 14 additions & 0 deletions src/entities/applet/lib/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,17 @@ export function onFlowActivityContainsAllItemsHidden(entityName: string) {
export function onDataSharingConsentDetails() {
Alert.alert('', i18n.t('data_sharing:dialog:body'));
}

export function onFlowCompletedElsewhere(onOk: () => void) {
Alert.alert(
i18n.t('activity:flow_completed'),
i18n.t('activity:flow_completed_elsewhere_message'),
[
{
text: i18n.t('additional:ok'),
onPress: onOk,
},
],
{ cancelable: false },
);
}
5 changes: 3 additions & 2 deletions src/entities/applet/model/hooks/useRefreshMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ export const useRefreshMutation = (onSuccess?: () => void | Promise<void>) => {
const queryClient = useQueryClient();

const progressSyncService = useMemo(
() => new ProgressSyncService(state, dispatch, getDefaultLogger()),
[dispatch, state],
() =>
new ProgressSyncService(state, dispatch, getDefaultLogger(), queryClient),
[dispatch, state, queryClient],
);

const refreshService = useMemo(
Expand Down
99 changes: 93 additions & 6 deletions src/entities/applet/model/hooks/useStartEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ import {
EvaluateAvailableTo,
LookupEntityInput,
} from '@app/abstract/lib/types/entity';
import { EntityProgressionInProgress } from '@app/abstract/lib/types/entityProgress';
import {
EntityProgression,
EntityProgressionInProgress,
} from '@app/abstract/lib/types/entityProgress';
import { ActivityRecordKeyParams } from '@app/abstract/lib/types/storage';
import { reduxStore } from '@app/app/ui/AppProvider/ReduxProvider';
import { useRefreshMutation } from '@app/entities/applet/model/hooks/useRefreshMutation';
import { ResponseType } from '@app/shared/api/services/ActivityItemDto';
import {
ActivityFlowRecordDto,
Expand Down Expand Up @@ -63,7 +68,8 @@ type FailReason =
| 'all-items-hidden'
| 'not-available'
| 'mutex-busy'
| 'expired-while-alert-opened';
| 'expired-while-alert-opened'
| 'completed-elsewhere';

type StartResult = {
fromScratch?: boolean;
Expand Down Expand Up @@ -111,6 +117,8 @@ export function useStartEntity({

const { getName: getAppletDisplayName } = useAppletInfo();

const { mutateAsync: refresh } = useRefreshMutation();

const logger: ILogger = getDefaultLogger();

function activityStart(
Expand Down Expand Up @@ -201,12 +209,15 @@ export function useStartEntity({
entityType: EntityType,
targetSubjectId: string | null,
): Promise<{ isEntityInProgress: boolean; availableTo: number | null }> {
const freshProgressions: EntityProgression[] =
selectAppletsEntityProgressions(reduxStore.getState());

const progression = getEntityProgression(
appletId,
entityId,
eventId,
targetSubjectId,
entityProgressions,
freshProgressions,
);

let evaluatedIsInProgress = isEntityProgressionInProgress(progression);
Expand All @@ -221,7 +232,7 @@ export function useStartEntity({

const readyForAutocompletion = isProgressionReadyForAutocompletion(
entityPath,
entityProgressions,
freshProgressions,
);

if (readyForAutocompletion) {
Expand Down Expand Up @@ -346,6 +357,8 @@ export function useStartEntity({
try {
mutex.setBusy();

await refresh();

if (
!(await checkAvailability(entityName, {
appletId,
Expand Down Expand Up @@ -471,6 +484,19 @@ export function useStartEntity({
return resolve({ failReason: 'expired-while-alert-opened' });
}

// Clear FlowState to ensure clean restart
const flowStateKey = getFlowRecordKey(
flowId,
appletId,
eventId,
targetSubjectId,
);
const flowStorage =
getDefaultStorageInstanceManager().getFlowProgressStorage();
flowStorage.delete(flowStateKey);

logger.log(`[useStartEntity.onRestart] Cleared FlowState`);

for (let i = 0; i < flowActivities.length; i++) {
// TODO: it should be based on progress record
cleanUpMediaFiles({
Expand Down Expand Up @@ -514,8 +540,28 @@ export function useStartEntity({
const storage =
getDefaultStorageInstanceManager().getFlowProgressStorage();

const flowState =
(JSON.parse(storage.getString(key) || '') as FlowState) || {};
let flowState: FlowState | undefined;
try {
const storedValue = storage.getString(key);
if (storedValue) {
flowState = JSON.parse(storedValue) as FlowState;
}
} catch (error) {
logger.error(
`[useStartEntity.onResume] Failed to parse flow state: ${error}`,
);
}

if (
!flowState ||
!flowState.pipeline ||
flowState.pipeline.length === 0
) {
logger.warn(
'[useStartEntity.onResume] No valid flow state found, starting from scratch',
);
return resolve({ fromScratch: true });
}

trackResumeFlow({
...logParams,
Expand Down Expand Up @@ -566,6 +612,47 @@ export function useStartEntity({
try {
mutex.setBusy();

// Capture pre-refresh state to detect cross-device completion
const preRefreshProgressions: EntityProgression[] =
selectAppletsEntityProgressions(reduxStore.getState());
const preRefreshProgression = getEntityProgression(
appletId,
flowId,
eventId,
targetSubjectId,
preRefreshProgressions,
);
const wasInProgress = isEntityProgressionInProgress(
preRefreshProgression,
);
const preRefreshSubmitId = wasInProgress
? (preRefreshProgression as EntityProgressionInProgress).submitId
: null;

await refresh();

// Check if the flow we started was completed on another device
if (wasInProgress && preRefreshSubmitId) {
const postRefreshProgressions: EntityProgression[] =
selectAppletsEntityProgressions(reduxStore.getState());
const postRefreshProgression = getEntityProgression(
appletId,
flowId,
eventId,
targetSubjectId,
postRefreshProgressions,
);

const isNowCompleted = postRefreshProgression?.status === 'completed';
const submitIdsMatch =
postRefreshProgression?.submitId === preRefreshSubmitId;

// SAME submitId completed elsewhere - show alert and block
if (isNowCompleted && submitIdsMatch) {
return { failed: true, failReason: 'completed-elsewhere' };
}
}

if (
!(await checkAvailability(entityName, {
appletId,
Expand Down
49 changes: 49 additions & 0 deletions src/entities/applet/model/hooks/useTargetedSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useCallback, useMemo, useRef, useState } from 'react';

import { useQueryClient } from '@tanstack/react-query';

import { useAppDispatch, useAppSelector } from '@app/shared/lib/hooks/redux';
import { getDefaultLogger } from '@app/shared/lib/services/loggerInstance';

import { selectGlobalState } from '../selectors';
import { TargetedProgressSyncService } from '../services/TargetedProgressSyncService';

// Hook for syncing single applet's progress without full refresh
export const useTargetedSync = () => {
const dispatch = useAppDispatch();
const state = useAppSelector(selectGlobalState);
const queryClient = useQueryClient();
const logger = getDefaultLogger();

const syncService = useMemo(
() => new TargetedProgressSyncService(state, dispatch, logger, queryClient),
[state, dispatch, logger, queryClient],
);

const isSyncing = useRef(false);
const [isRefreshing, setIsRefreshing] = useState(false);

const syncApplet = useCallback(
async (appletId: string): Promise<void> => {
if (isSyncing.current) {
logger.log('[useTargetedSync]: Sync already in progress, skipping');
return;
}

try {
isSyncing.current = true;
setIsRefreshing(true);
await syncService.syncAppletProgress(appletId);
} finally {
isSyncing.current = false;
setIsRefreshing(false);
}
},
[syncService, logger],
);

return {
syncApplet,
isRefreshing,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface ITargetedProgressSyncService {
// Syncs in-progress flows and completions for a single applet
syncAppletProgress(appletId: string): Promise<void>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class ProgressDataCollector implements IProgressDataCollector {

return await getDefaultEventsService().getAllCompletedEntities({
fromDate,
includeInProgress: true,
});
}

Expand Down
Loading
Loading