From 4a4208e33602d65ef9942d05e54a692addd87e2f Mon Sep 17 00:00:00 2001 From: Ecasept <76942454+Ecasept@users.noreply.github.com> Date: Thu, 23 Jul 2026 05:40:14 +0200 Subject: [PATCH] feat: Add Time-Tracking Toggle and Inactivity Timeout (#1890) * feat: implement on-off switch and inactivity timeout for time-tracking * chore: remove stray console.log --- assets/reader/js/core.js | 22 ++ src/hooks/persisted/useSettings.ts | 14 ++ src/screens/reader/ReaderScreen.tsx | 9 +- .../reader/components/WebViewReader.tsx | 12 +- .../reader/hooks/__tests__/useChapter.test.ts | 3 + .../hooks/__tests__/useTimeTracking.test.ts | 199 +++++++++++++++--- src/screens/reader/hooks/useChapter.ts | 8 +- src/screens/reader/hooks/useTimeTracking.ts | 42 ++-- .../SettingsGeneralScreen.tsx | 34 +++ .../modals/InactivityTimeoutModal.tsx | 64 ++++++ strings/languages/en/strings.json | 12 +- strings/types/index.ts | 6 + 12 files changed, 371 insertions(+), 54 deletions(-) create mode 100644 src/screens/settings/SettingsGeneralScreen/modals/InactivityTimeoutModal.tsx diff --git a/assets/reader/js/core.js b/assets/reader/js/core.js index 67864128..4c94c1d7 100644 --- a/assets/reader/js/core.js +++ b/assets/reader/js/core.js @@ -1,3 +1,19 @@ +window.onUserInteraction = (() => { + let groupStart = 0; + // Group multiple interactions within a short time frame to avoid excessive calls + const groupTime = 3000; + return () => { + let now = Date.now(); + if (now - groupStart < groupTime) { + // We're inside the group + } else { + // We're the first in a while + groupStart = now; + reader.post({ type: 'interaction' }); + } + } +})(); + /* eslint-disable no-console */ window.reader = new (function () { const { @@ -100,6 +116,7 @@ window.reader = new (function () { }); document.onscrollend = () => { + onUserInteraction(); if (!this.generalSettings.val.pageReader) { this.post({ type: 'save', @@ -111,6 +128,10 @@ window.reader = new (function () { } }; + document.onpointerdown = () => onUserInteraction(); + document.onpointermove = () => onUserInteraction(); + document.onpointerup = () => onUserInteraction(); + if (DEBUG) { // eslint-disable-next-line no-global-assign, no-new-object console = new Object(); @@ -505,6 +526,7 @@ window.pageReader = new (function () { }; this.movePage = destPage => { + onUserInteraction(); if (this.chapterEndingVisible.val) { if (destPage < 0) { this.showChapterEnding(false); diff --git a/src/hooks/persisted/useSettings.ts b/src/hooks/persisted/useSettings.ts index 97d3becc..2ad55bb8 100644 --- a/src/hooks/persisted/useSettings.ts +++ b/src/hooks/persisted/useSettings.ts @@ -80,6 +80,14 @@ export interface AppSettings { hideBackdrop: boolean; defaultChapterSort: ChapterOrderKey; + + /** + * Time-tracking settings + */ + /** Whether to actually enable time tracking */ + timeTrackingEnabled: boolean; + /** The timeout after which to consider the user inactive and not track time */ + inactivityTimeoutMs?: number; } export interface BrowseSettings { @@ -188,6 +196,12 @@ const initialAppSettings: AppSettings = { hideBackdrop: false, defaultChapterSort: 'positionAsc', + + /** + * Time-tracking settings + */ + timeTrackingEnabled: true, + inactivityTimeoutMs: 5 * 60 * 1000, // 5 minutes }; const initialBrowseSettings: BrowseSettings = { diff --git a/src/screens/reader/ReaderScreen.tsx b/src/screens/reader/ReaderScreen.tsx index ef9527b7..01cc86bc 100644 --- a/src/screens/reader/ReaderScreen.tsx +++ b/src/screens/reader/ReaderScreen.tsx @@ -65,7 +65,7 @@ export const ChapterContent = ({ openDrawer, }: ChapterContentProps) => { const { left, right } = useSafeAreaInsets(); - const { novel, chapter } = useChapterContext(); + const { novel, chapter, onUserInteraction } = useChapterContext(); const readerSheetRef = useRef(null); const theme = useTheme(); const { pageReader = false, keepScreenOn } = useChapterGeneralSettings(); @@ -135,7 +135,8 @@ export const ChapterContent = ({ `); }, [hidden, searchVisible, webViewRef]); - const scrollToStart = () => + const scrollToStart = () => { + onUserInteraction(); requestAnimationFrame(() => { webViewRef?.current?.injectJavaScript( !pageReader @@ -147,7 +148,8 @@ export const ChapterContent = ({ document.querySelector("chapter").style.transform = 'translate(0%)'; })()`, ); - }); + }) + }; const openDrawerI = useCallback(() => { openDrawer(); @@ -161,6 +163,7 @@ export const ChapterContent = ({ }, [searchVisible]); const handleReaderPress = useCallback(() => { + onUserInteraction(); if (searchVisible) { setSearchVisible(false); return; diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index 3bf7b69e..2fa3a531 100644 --- a/src/screens/reader/components/WebViewReader.tsx +++ b/src/screens/reader/components/WebViewReader.tsx @@ -95,6 +95,8 @@ const WebViewReader: React.FC = ({ nextChapter, prevChapter, webViewRef, + onUserInteraction, + isTTSReadingRef } = useChapterContext(); const theme = useTheme(); const initialReaderSettings = useMemo( @@ -121,7 +123,6 @@ const WebViewReader: React.FC = ({ const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`; const nextChapterScreenVisible = useRef(false); const autoStartTTSRef = useRef(false); - const isTTSReadingRef = useRef(false); const appStateRef = useRef(AppState.currentState); const ttsQueueRef = useRef([]); const ttsQueueIndexRef = useRef(0); @@ -210,7 +211,7 @@ const WebViewReader: React.FC = ({ isPlaying: isTTSReadingRef.current, }); } - }, [novel?.name, novel?.cover, chapter.name]); + }, [novel?.name, novel?.cover, chapter.name, isTTSReadingRef]); useEffect(() => { return () => { @@ -301,11 +302,12 @@ const WebViewReader: React.FC = ({ }); return () => subscription.remove(); - }, [webViewRef]); + }, [webViewRef, isTTSReadingRef]); const speakText = (text: string) => { Speech.speak(text, { onDone() { + onUserInteraction(); const isBackground = appStateRef.current === 'background' || appStateRef.current === 'inactive'; @@ -505,10 +507,14 @@ const WebViewReader: React.FC = ({ if (event.data && typeof event.data === 'object') { const data = event.data as { isReading?: boolean }; const isReading = data.isReading === true; + onUserInteraction(); isTTSReadingRef.current = isReading; updateTTSPlaybackState(isReading); } break; + case 'interaction': + onUserInteraction(); + break; } }} source={{ diff --git a/src/screens/reader/hooks/__tests__/useChapter.test.ts b/src/screens/reader/hooks/__tests__/useChapter.test.ts index 7fc096d2..ae5b9733 100644 --- a/src/screens/reader/hooks/__tests__/useChapter.test.ts +++ b/src/screens/reader/hooks/__tests__/useChapter.test.ts @@ -5,6 +5,7 @@ import NativeFile from '@modules/native-file' const mockUseNovelActions = jest.fn(); const mockUseChapterGeneralSettings = jest.fn(); const mockUseLibrarySettings = jest.fn(); +const mockUseAppSettings = jest.fn(); const mockUseTracker = jest.fn(); const mockUseTrackedNovel = jest.fn(); const mockUseFullscreenMode = jest.fn(); @@ -30,6 +31,7 @@ jest.mock('@screens/novel/NovelContext', () => ({ jest.mock('@hooks/persisted', () => ({ useChapterGeneralSettings: () => mockUseChapterGeneralSettings(), useLibrarySettings: () => mockUseLibrarySettings(), + useAppSettings: () => mockUseAppSettings(), useTracker: () => mockUseTracker(), useTrackedNovel: (...args: unknown[]) => mockUseTrackedNovel(...args), })); @@ -154,6 +156,7 @@ describe('useChapter', () => { volumeButtonsOffset: 100, }); mockUseLibrarySettings.mockReturnValue({ incognitoMode: false }); + mockUseAppSettings.mockReturnValue({ timeTrackingEnabled: true, inactivityTimeoutMs: 60000 }); mockUseTracker.mockReturnValue({ tracker: { id: 'tracker' } }); mockUseTrackedNovel.mockReturnValue({ trackedNovel: { progress: 1 }, diff --git a/src/screens/reader/hooks/__tests__/useTimeTracking.test.ts b/src/screens/reader/hooks/__tests__/useTimeTracking.test.ts index aa9f32a9..8bad13ce 100644 --- a/src/screens/reader/hooks/__tests__/useTimeTracking.test.ts +++ b/src/screens/reader/hooks/__tests__/useTimeTracking.test.ts @@ -24,50 +24,37 @@ describe('useTimeTracking', () => { jest.useRealTimers(); }); - it('flushes elapsed time on the 60s interval', () => { - renderHook(() => useTimeTracking(1, false, increaseTimeSpent)); - - act(() => { - jest.advanceTimersByTime(60000); - }); - - expect(increaseTimeSpent).toHaveBeenCalledWith(1, expect.any(Number)); - const elapsed = increaseTimeSpent.mock.calls[0][1]; - expect(elapsed).toBeGreaterThanOrEqual(60000); - }); - it('flushes when the app goes to the background', () => { - renderHook(() => useTimeTracking(1, false, increaseTimeSpent)); + renderHook(() => useTimeTracking(1, false, 60000, true, increaseTimeSpent)); act(() => { jest.advanceTimersByTime(5000); appStateHandler('background'); }); - expect(increaseTimeSpent).toHaveBeenCalledWith(1, expect.any(Number)); + expect(increaseTimeSpent).toHaveBeenCalledWith(1, 5000); }); it('does not double count after backgrounding and foregrounding', () => { - renderHook(() => useTimeTracking(1, false, increaseTimeSpent)); + renderHook(() => useTimeTracking(1, false, 60000, true, increaseTimeSpent)); act(() => { jest.advanceTimersByTime(5000); - appStateHandler('background'); // flush 5s + appStateHandler('background'); jest.advanceTimersByTime(20000); appStateHandler('active'); jest.advanceTimersByTime(3000); appStateHandler('background'); // flush 3s }); - const firstElapsed = increaseTimeSpent.mock.calls[0][1]; - expect(firstElapsed).toBeLessThan(6000); // 5s flush - const secondElapsed = increaseTimeSpent.mock.calls[1][1]; - expect(secondElapsed).toBeLessThan(4000); // 3s flush + expect(increaseTimeSpent).toHaveBeenNthCalledWith(1, 1, 5000); + expect(increaseTimeSpent).toHaveBeenNthCalledWith(2, 1, 3000); + expect(increaseTimeSpent).toHaveBeenCalledTimes(2); }); it('flushes remaining time on unmount', () => { const { unmount } = renderHook(() => - useTimeTracking(1, false, increaseTimeSpent), + useTimeTracking(1, false, 60000, true, increaseTimeSpent), ); act(() => { @@ -76,13 +63,13 @@ describe('useTimeTracking', () => { unmount(); - expect(increaseTimeSpent).toHaveBeenLastCalledWith(1, expect.any(Number)); + expect(increaseTimeSpent).toHaveBeenLastCalledWith(1, 10000); }); it('flushes for the previous chapter id when chapterId changes', () => { const { rerender } = renderHook( ({ chapterId }: { chapterId: number }) => - useTimeTracking(chapterId, false, increaseTimeSpent), + useTimeTracking(chapterId, false, 60000, true, increaseTimeSpent), { initialProps: { chapterId: 1 } }, ); @@ -92,17 +79,175 @@ describe('useTimeTracking', () => { rerender({ chapterId: 2 }); - expect(increaseTimeSpent).toHaveBeenCalledWith(1, expect.any(Number)); + expect(increaseTimeSpent).toHaveBeenCalledWith(1, 10000); expect(increaseTimeSpent).not.toHaveBeenCalledWith(2, expect.any(Number)); }); - it('does not track time in incognito mode', () => { - renderHook(() => useTimeTracking(1, true, increaseTimeSpent)); + it('flushes after user interaction', () => { + const onUserInteraction = renderHook(() => + useTimeTracking(1, false, 60000, true, increaseTimeSpent), + ).result.current.onUserInteraction; act(() => { - jest.advanceTimersByTime(60000); + jest.advanceTimersByTime(10000); + onUserInteraction(); + }); + + expect(increaseTimeSpent).toHaveBeenCalledWith(1, 10000); + }); + + it('does not track time when time tracking is turned off', () => { + const onUserInteraction = renderHook(() => useTimeTracking(1, false, 60000, false, increaseTimeSpent)).result.current.onUserInteraction; + + act(() => { + jest.advanceTimersByTime(6000); + onUserInteraction(); }); expect(increaseTimeSpent).not.toHaveBeenCalled(); }); + + it('does not track time when the user is inactive for too long', () => { + const onUserInteraction = renderHook(() => useTimeTracking(1, false, 60000, true, increaseTimeSpent)).result.current.onUserInteraction; + + act(() => { + jest.advanceTimersByTime(70000); + onUserInteraction(); + }); + + expect(increaseTimeSpent).not.toHaveBeenCalled(); + }); + + it('resets inactivity timeout correctly', () => { + const onUserInteraction = renderHook(() => useTimeTracking(1, false, 60000, true, increaseTimeSpent)).result.current.onUserInteraction; + + act(() => { + jest.advanceTimersByTime(30000); + onUserInteraction(); + jest.advanceTimersByTime(30000); + onUserInteraction(); + }); + + expect(increaseTimeSpent).toHaveBeenNthCalledWith(1, 1, 30000); + expect(increaseTimeSpent).toHaveBeenNthCalledWith(2, 1, 30000); + expect(increaseTimeSpent).toHaveBeenCalledTimes(2); + }); + + it('tracks time accurately when the user interacts multiple times in quick succession', () => { + const onUserInteraction = renderHook(() => useTimeTracking(1, false, 60000, true, increaseTimeSpent)).result.current.onUserInteraction; + + act(() => { + jest.advanceTimersByTime(30000); + onUserInteraction(); + onUserInteraction(); // clock didn't advance, so shouldn't call increaseTimeSpent again + jest.advanceTimersByTime(2); + onUserInteraction(); + jest.advanceTimersByTime(20000); + onUserInteraction(); + }); + + expect(increaseTimeSpent).toHaveBeenNthCalledWith(1, 1, 30000); + expect(increaseTimeSpent).toHaveBeenNthCalledWith(2, 1, 2); + expect(increaseTimeSpent).toHaveBeenNthCalledWith(3, 1, 20000); + expect(increaseTimeSpent).toHaveBeenCalledTimes(3); + }); + + it('tracks time accurately even when the user goes inactive for a while', () => { + const onUserInteraction = renderHook(() => useTimeTracking(1, false, 60000, true, increaseTimeSpent)).result.current.onUserInteraction; + + act(() => { + jest.advanceTimersByTime(30000); + onUserInteraction(); + jest.advanceTimersByTime(10000); + onUserInteraction(); + jest.advanceTimersByTime(50000); + onUserInteraction(); + + // Inactivity + jest.advanceTimersByTime(100000); + onUserInteraction(); // shouldn't do anything + jest.advanceTimersByTime(30000); + onUserInteraction(); + }); + + expect(increaseTimeSpent).toHaveBeenNthCalledWith(1, 1, 30000); + expect(increaseTimeSpent).toHaveBeenNthCalledWith(2, 1, 10000); + expect(increaseTimeSpent).toHaveBeenNthCalledWith(3, 1, 50000); + expect(increaseTimeSpent).toHaveBeenNthCalledWith(4, 1, 30000); + + expect(increaseTimeSpent).toHaveBeenCalledTimes(4); + }); + + it('does not track time in incognito mode', () => { + const onUserInteraction = renderHook(() => useTimeTracking(1, true, 60000, true, increaseTimeSpent)).result.current.onUserInteraction; + + act(() => { + jest.advanceTimersByTime(6000); + onUserInteraction(); + }); + + expect(increaseTimeSpent).not.toHaveBeenCalled(); + }); + it('tracks time when TTS is reading even if the user is inactive for too long', () => { + const { result } = renderHook(() => + useTimeTracking(1, false, 60000, true, increaseTimeSpent), + ); + + act(() => { + result.current.isTTSReadingRef.current = true; + jest.advanceTimersByTime(70000); + result.current.onUserInteraction(); + }); + + expect(increaseTimeSpent).toHaveBeenCalledWith(1, 70000); + }); + + it('tracks time when TTS is reading even if the app goes to the background', () => { + const { result } = renderHook(() => + useTimeTracking(1, false, 60000, true, increaseTimeSpent), + ); + + act(() => { + result.current.isTTSReadingRef.current = true; + jest.advanceTimersByTime(70000); + appStateHandler('background'); + jest.advanceTimersByTime(10000); + result.current.onUserInteraction(); + }); + + expect(increaseTimeSpent).toHaveBeenNthCalledWith(1, 1, 70000); // on background + expect(increaseTimeSpent).toHaveBeenNthCalledWith(2, 1, 10000); // on user interaction after background + }); + it('does not double-count when onUserInteraction is called back-to-back during TTS', () => { + const { result } = renderHook(() => + useTimeTracking(1, false, 60000, true, increaseTimeSpent), + ); + + act(() => { + result.current.isTTSReadingRef.current = true; + jest.advanceTimersByTime(5000); + result.current.onUserInteraction(); + result.current.onUserInteraction(); + }); + + expect(increaseTimeSpent).toHaveBeenNthCalledWith(1, 1, 5000); + expect(increaseTimeSpent).toHaveBeenCalledTimes(1); + }); + it('resumes normal inactivity gating once TTS stops', () => { + const { result } = renderHook(() => + useTimeTracking(1, false, 60000, true, increaseTimeSpent), + ); + + act(() => { + result.current.isTTSReadingRef.current = true; + jest.advanceTimersByTime(5000); + result.current.onUserInteraction(); + result.current.isTTSReadingRef.current = false; + jest.advanceTimersByTime(70000); + result.current.onUserInteraction(); + }); + + expect(increaseTimeSpent).toHaveBeenNthCalledWith(1, 1, 5000); + expect(increaseTimeSpent).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/screens/reader/hooks/useChapter.ts b/src/screens/reader/hooks/useChapter.ts index 5fe2d4ea..9f1f8b32 100644 --- a/src/screens/reader/hooks/useChapter.ts +++ b/src/screens/reader/hooks/useChapter.ts @@ -8,6 +8,7 @@ import { import { insertHistory } from '@database/queries/HistoryQueries'; import { ChapterInfo, NovelInfo } from '@database/types'; import { + useAppSettings, useChapterGeneralSettings, useLibrarySettings, useTrackedNovel, @@ -70,12 +71,13 @@ export default function useChapter( volumeButtonsOffset, } = useChapterGeneralSettings(); const { incognitoMode } = useLibrarySettings(); + const { timeTrackingEnabled, inactivityTimeoutMs } = useAppSettings(); const [error, setError] = useState(); const { tracker } = useTracker(); const { trackedNovel, updateAllTrackedNovels } = useTrackedNovel(novel.id); const { setImmersiveMode, showStatusAndNavBar } = useFullscreenMode(); - useTimeTracking(chapter.id, incognitoMode || false, increaseTimeSpent); + const { onUserInteraction, isTTSReadingRef } = useTimeTracking(chapter.id, incognitoMode || false, inactivityTimeoutMs, timeTrackingEnabled, increaseTimeSpent); const connectVolumeButton = useCallback(() => { const offset = defaultTo( @@ -411,6 +413,8 @@ export default function useChapter( setChapter, setLoading, getChapter, + onUserInteraction, + isTTSReadingRef, }), [ hidden, @@ -431,6 +435,8 @@ export default function useChapter( setChapter, setLoading, getChapter, + onUserInteraction, + isTTSReadingRef, ], ); } diff --git a/src/screens/reader/hooks/useTimeTracking.ts b/src/screens/reader/hooks/useTimeTracking.ts index 723621cf..5ffb0f6b 100644 --- a/src/screens/reader/hooks/useTimeTracking.ts +++ b/src/screens/reader/hooks/useTimeTracking.ts @@ -3,33 +3,33 @@ import { AppState, AppStateStatus } from 'react-native'; /** - * Starts tracking time spent on the chapter, and flushes the data to the database: - * - periodically every minute - * - when the app goes in the background - * - when the chapter changes + * Starts tracking time spent on the chapter and returns a callback to be called on user interaction (to prevent the inactivity timeout from being triggered). */ export default function useTimeTracking( chapterId: number, incognitoMode: boolean, + inactivityTimeoutMs: number | undefined, + turnedOn: boolean, increaseTimeSpent: (chapterId: number, elapsed: number) => void, ) { - const startTimestampRef = useRef(Date.now()); + const lastUserInteractionRef = useRef(Date.now()); + const isTTSReadingRef = useRef(false); - /** Increases the time spent on the current chapter since the last time this function was called */ + /** Increases the time spent on the current chapter since the last user interaction, as long as it was not too long ago */ const flushElapsedTime = useCallback(() => { - if (!incognitoMode) { - const elapsed = Date.now() - startTimestampRef.current; + const enabled = turnedOn && !incognitoMode; + const isInactive = inactivityTimeoutMs !== undefined && Date.now() - lastUserInteractionRef.current > inactivityTimeoutMs; + if (enabled && (!isInactive || isTTSReadingRef.current)) { + const elapsed = Date.now() - lastUserInteractionRef.current; if (elapsed > 0) { increaseTimeSpent(chapterId, elapsed); } } - startTimestampRef.current = Date.now(); - }, [chapterId, incognitoMode, increaseTimeSpent]); + }, [chapterId, incognitoMode, inactivityTimeoutMs, turnedOn, increaseTimeSpent]); - // Flush the elapsed time every minute - useEffect(() => { - const intervalId = setInterval(flushElapsedTime, 60000); - return () => clearInterval(intervalId); + const onUserInteraction = useCallback(() => { + flushElapsedTime(); + lastUserInteractionRef.current = Date.now(); }, [flushElapsedTime]); // Flush the elapsed time when the app goes in the background @@ -39,9 +39,16 @@ export default function useTimeTracking( if (appState === 'active' && nextState.match(/inactive|background/)) { // Save the elapsed time when the app goes in the background flushElapsedTime(); + lastUserInteractionRef.current = Date.now(); } else if (appState.match(/inactive|background/) && nextState === 'active') { - // Reset the start timestamp when the app comes back - startTimestampRef.current = Date.now(); + // Reset the start timestamp when the app comes back and the TTS isn't on + if (!isTTSReadingRef.current) { + lastUserInteractionRef.current = Date.now(); + } else { + // If TTS is on, we want to count the time spent in the background as well + flushElapsedTime(); + lastUserInteractionRef.current = Date.now(); + } } appState = nextState; }; @@ -51,9 +58,10 @@ export default function useTimeTracking( // Flush the elapsed time when the chapter changes useEffect(() => { - startTimestampRef.current = Date.now(); + lastUserInteractionRef.current = Date.now(); return () => { flushElapsedTime(); }; }, [chapterId, flushElapsedTime]); + return { onUserInteraction, isTTSReadingRef }; } diff --git a/src/screens/settings/SettingsGeneralScreen/SettingsGeneralScreen.tsx b/src/screens/settings/SettingsGeneralScreen/SettingsGeneralScreen.tsx index 5547971f..a8f5d5c3 100644 --- a/src/screens/settings/SettingsGeneralScreen/SettingsGeneralScreen.tsx +++ b/src/screens/settings/SettingsGeneralScreen/SettingsGeneralScreen.tsx @@ -24,6 +24,7 @@ import NovelBadgesModal from './modals/NovelBadgesModal'; import { NavigationState } from '@react-navigation/native'; import { getString } from '@strings/translations'; import SettingSwitch from '../components/SettingSwitch'; +import InactivityTimeoutModal from './modals/InactivityTimeoutModal'; interface GenralSettingsProps { navigation: NavigationState; @@ -60,6 +61,8 @@ const GenralSettings: React.FC = ({ navigation }) => { disableHapticFeedback, useLibraryFAB, chapterDownloadCooldownMs, + timeTrackingEnabled, + inactivityTimeoutMs, setAppSettings, } = useAppSettings(); @@ -106,6 +109,10 @@ const GenralSettings: React.FC = ({ navigation }) => { * Download Cooldown Modal */ const downloadCooldownModalRef = useBoolean(); + /** + * Inactivity Timeout Modal + */ + const inactivityTimeoutModalRef = useBoolean(); return ( = ({ navigation }) => { theme={theme} /> + + {getString('generalSettingsScreen.timeTracking')} + + + setAppSettings({ timeTrackingEnabled: !timeTrackingEnabled }) + } + theme={theme} + /> + + {getString('generalSettings')} @@ -299,6 +327,12 @@ const GenralSettings: React.FC = ({ navigation }) => { hideModal={downloadCooldownModalRef.setFalse} theme={theme} /> + ); }; diff --git a/src/screens/settings/SettingsGeneralScreen/modals/InactivityTimeoutModal.tsx b/src/screens/settings/SettingsGeneralScreen/modals/InactivityTimeoutModal.tsx new file mode 100644 index 00000000..19de0ca7 --- /dev/null +++ b/src/screens/settings/SettingsGeneralScreen/modals/InactivityTimeoutModal.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Text, StyleSheet } from 'react-native'; + +import { Portal } from 'react-native-paper'; + +import { RadioButton } from '@components/RadioButton/RadioButton'; +import { ThemeColors } from '@theme/types'; +import { useAppSettings } from '@hooks/persisted'; +import { getString } from '@strings/translations'; +import { Modal } from '@components'; + +interface InactivityTimeoutModalProps { + inactivityTimeoutMs: number | undefined; + modalVisible: boolean; + hideModal: () => void; + theme: ThemeColors; +} + +const InactivityTimeoutModal: React.FC = ({ + theme, + inactivityTimeoutMs, + hideModal, + modalVisible, +}) => { + const { setAppSettings } = useAppSettings(); + + return ( + + + + {getString('generalSettingsScreen.inactivityTimeout')} + + + {getString('generalSettingsScreen.inactivityTimeoutDesc')} + + setAppSettings({ inactivityTimeoutMs: undefined })} + theme={theme} + /> + {[1, 3, 5, 10, 15, 30].map(time => ( + setAppSettings({ inactivityTimeoutMs: time * 60 * 1000 })} + label={`${getString('time.minutes', { count: time })}`} + theme={theme} + /> + ))} + + + ); +}; + +export default InactivityTimeoutModal; + +const styles = StyleSheet.create({ + modalHeader: { + fontSize: 24, + marginBottom: 10, + }, +}); diff --git a/strings/languages/en/strings.json b/strings/languages/en/strings.json index 247d2ab5..18a01e38 100644 --- a/strings/languages/en/strings.json +++ b/strings/languages/en/strings.json @@ -305,7 +305,13 @@ "updateLibraryDesc": "Not recommended for low devices", "updateOngoing": "Only update ongoing novels", "updateTime": "Show last update time", - "useFAB": "Use FAB in Library" + "useFAB": "Use FAB in Library", + "timeTracking": "Time Tracking", + "enableTimeTracking": "Enable time tracking", + "enableTimeTrackingDesc": "Tracks time spent reading and displays it in the statistics screen", + "inactivityTimeout": "Inactivity Timeout", + "inactivityTimeoutNever": "Never", + "inactivityTimeoutDesc": "Automatically pause time tracking after a period of inactivity" }, "globalSearch": { "allSources": "all sources", @@ -543,8 +549,8 @@ "topNovelsByTimeSpent": "Top novels by time spent", "topCategoriesByTimeSpent": "Top categories by time spent", "totalTimeSpent": "Total time spent", - "showNovels": "Group by novel", - "showCategories": "Group by category" + "showNovels": "Group by novel", + "showCategories": "Group by category" }, "tracking": "Tracking", "trackingScreen": { diff --git a/strings/types/index.ts b/strings/types/index.ts index ac89e4f4..960ab38a 100644 --- a/strings/types/index.ts +++ b/strings/types/index.ts @@ -269,6 +269,12 @@ export interface StringMap { 'generalSettingsScreen.updateOngoing': 'string'; 'generalSettingsScreen.updateTime': 'string'; 'generalSettingsScreen.useFAB': 'string'; + 'generalSettingsScreen.timeTracking': 'string'; + 'generalSettingsScreen.enableTimeTracking': 'string'; + 'generalSettingsScreen.enableTimeTrackingDesc': 'string'; + 'generalSettingsScreen.inactivityTimeout': 'string'; + 'generalSettingsScreen.inactivityTimeoutNever': 'string'; + 'generalSettingsScreen.inactivityTimeoutDesc': 'string'; 'globalSearch.allSources': 'string'; 'globalSearch.searchIn': 'string'; 'history': 'string';