From f9c629d145961f96d5c6432ff43a80a1a11cbb7f Mon Sep 17 00:00:00 2001 From: Justin Wyne <1986068+wyne@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:19:10 -0400 Subject: [PATCH 1/2] feat: make gesture interaction type a per-game setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move interactionType from a single global SettingsSlice value to an optional per-game field, with the global value retained as the default for new games. - Add optional `interactionType` to GameState and a `setGameInteractionType` reducer (no persist migration needed; the field is optional and resolves via fallback when unset) - `selectInteractionType(state, gameId?)` now prefers the game-level gesture, falls back to the global default, then validates - GameOptionsButton sets the gesture on the current game and also updates the global default, preserving the "sticky last choice" so new games inherit the most recently picked gesture - Route all read paths (GameScreen, PlayerTile, useGestureHint, PointValuesSheet) through the game-scoped selector - Tests: game-level precedence/fallback in selectors, per-game switch in useGestureHint, and a games slice added to the GameOptionsButton store No threshold/enforcement logic — that is intentionally deferred. Co-Authored-By: Claude Opus 4.8 --- redux/GamesSlice.ts | 16 ++++++ redux/selectors.test.ts | 57 ++++++++++++++++++- redux/selectors.ts | 6 +- src/components/Boards/PlayerTile.tsx | 4 +- .../Buttons/GameOptionsButton.test.tsx | 9 ++- src/components/Buttons/GameOptionsButton.tsx | 23 +++++--- src/components/Sheets/PointValuesSheet.tsx | 3 +- src/hooks/useGestureHint.test.ts | 12 +++- src/hooks/useGestureHint.ts | 2 +- src/screens/GameScreen.tsx | 2 +- 10 files changed, 118 insertions(+), 16 deletions(-) diff --git a/redux/GamesSlice.ts b/redux/GamesSlice.ts index 96a5f2b0..a0b6a336 100644 --- a/redux/GamesSlice.ts +++ b/redux/GamesSlice.ts @@ -4,6 +4,7 @@ import * as Crypto from 'expo-crypto'; import { logEvent } from '../src/Analytics'; import { getPalette } from '../src/ColorPalette'; +import { InteractionType } from '../src/components/Interactions/InteractionType'; import { SortDirectionKey, SortSelectorKey } from '../src/components/ScoreLog/SortHelper'; import logger from '../src/Logger'; @@ -23,6 +24,9 @@ export interface GameState { sortSelectorKey?: SortSelectorKey; sortDirectionKey?: SortDirectionKey; palette?: string; + // Per-game gesture. When unset, falls back to the global default + // (settings.interactionType) via selectInteractionType. + interactionType?: InteractionType; } const gamesAdapter = createEntityAdapter({ @@ -105,6 +109,17 @@ const gamesSlice = createSlice({ } }); }, + setGameInteractionType(state, action: PayloadAction<{ gameId: string, interactionType: InteractionType; }>) { + const game = state.entities[action.payload.gameId]; + if (!game) { return; } + + gamesAdapter.updateOne(state, { + id: action.payload.gameId, + changes: { + interactionType: action.payload.interactionType, + } + }); + }, restoreAllGames(state, action: PayloadAction>) { gamesAdapter.setAll(state, action.payload); }, @@ -324,6 +339,7 @@ export const { gameDelete, setSortSelector, reorderPlayers, + setGameInteractionType, restoreAllGames, } = gamesSlice.actions; diff --git a/redux/selectors.test.ts b/redux/selectors.test.ts index 9d1feddd..956bf408 100644 --- a/redux/selectors.test.ts +++ b/redux/selectors.test.ts @@ -70,11 +70,66 @@ describe('Redux selectors', () => { interactionType, }, } as RootState; - + const result = selectInteractionType(state); expect(result).toBe(interactionType); }); }); + + it('should prefer the game-level interaction type over the global default', () => { + const state = { + ...mockState, + settings: { + ...mockState.settings!, + interactionType: InteractionType.SwipeVertical, + }, + games: { + ...mockState.games!, + entities: { + 'game-1': { + ...mockState.games!.entities['game-1']!, + interactionType: InteractionType.Dial, + }, + }, + }, + } as RootState; + + expect(selectInteractionType(state, 'game-1')).toBe(InteractionType.Dial); + }); + + it('should fall back to the global default when the game has no interaction type', () => { + const state = { + ...mockState, + settings: { + ...mockState.settings!, + interactionType: InteractionType.HalfTap, + }, + } as RootState; + + // game-1 has no interactionType set + expect(selectInteractionType(state, 'game-1')).toBe(InteractionType.HalfTap); + }); + + it('should fall back to the global default when no gameId is provided', () => { + const state = { + ...mockState, + settings: { + ...mockState.settings!, + interactionType: InteractionType.HalfTap, + }, + games: { + ...mockState.games!, + entities: { + 'game-1': { + ...mockState.games!.entities['game-1']!, + interactionType: InteractionType.Dial, + }, + }, + }, + } as RootState; + + expect(selectInteractionType(state)).toBe(InteractionType.HalfTap); + }); }); describe('selectCurrentGame', () => { diff --git a/redux/selectors.ts b/redux/selectors.ts index 20283b8a..13928e09 100644 --- a/redux/selectors.ts +++ b/redux/selectors.ts @@ -3,8 +3,10 @@ import { InteractionType } from '../src/components/Interactions/InteractionType' import { selectGameById } from './GamesSlice'; import { RootState } from './store'; -export const selectInteractionType = (state: RootState) => { - const interactionType: InteractionType = state.settings.interactionType; +export const selectInteractionType = (state: RootState, gameId?: string): InteractionType => { + // Prefer the game-level gesture; fall back to the global default for new games. + const gameLevel = gameId ? selectGameById(state, gameId)?.interactionType : undefined; + const interactionType: InteractionType = gameLevel ?? state.settings.interactionType; // Check if interactionType is a valid InteractionType const isValidInteractionType = Object.values(InteractionType).includes(interactionType); diff --git a/src/components/Boards/PlayerTile.tsx b/src/components/Boards/PlayerTile.tsx index fa08cde2..e99e34d4 100644 --- a/src/components/Boards/PlayerTile.tsx +++ b/src/components/Boards/PlayerTile.tsx @@ -64,7 +64,9 @@ const PlayerTile: React.FunctionComponent = React.memo(({ const heightPerc: DimensionValue = `${(100 / rows)}%`; // Dynamic InteractionComponent - const interactionType: InteractionType = useAppSelector(selectInteractionType); + const interactionType: InteractionType = useAppSelector( + state => selectInteractionType(state, state.settings.currentGameId) + ); const InteractionComponent = interactionComponents[interactionType]; return ( diff --git a/src/components/Buttons/GameOptionsButton.test.tsx b/src/components/Buttons/GameOptionsButton.test.tsx index a1d5bd80..b97d0650 100644 --- a/src/components/Buttons/GameOptionsButton.test.tsx +++ b/src/components/Buttons/GameOptionsButton.test.tsx @@ -4,6 +4,7 @@ import { configureStore } from '@reduxjs/toolkit'; import { render } from '@testing-library/react-native'; import { Provider } from 'react-redux'; +import gamesReducer from '../../../redux/GamesSlice'; import settingsReducer from '../../../redux/SettingsSlice'; import { FEATURE_DIAL_GESTURE } from '../../constants'; @@ -53,7 +54,7 @@ import GameOptionsButton from './GameOptionsButton'; const createStore = (seenFeatureNotifications: string[] = []) => configureStore({ - reducer: { settings: settingsReducer }, + reducer: { settings: settingsReducer, games: gamesReducer }, preloadedState: { settings: { currentGameId: 'game-1', @@ -63,6 +64,12 @@ const createStore = (seenFeatureNotifications: string[] = []) => addendTwo: 10, seenFeatureNotifications, }, + games: { + entities: { + 'game-1': { id: 'game-1', playerIds: ['p1'], dateCreated: 0, roundCurrent: 0, roundTotal: 1 }, + }, + ids: ['game-1'], + }, } as Parameters[0]['preloadedState'], }); diff --git a/src/components/Buttons/GameOptionsButton.tsx b/src/components/Buttons/GameOptionsButton.tsx index caa743c4..190ff83b 100644 --- a/src/components/Buttons/GameOptionsButton.tsx +++ b/src/components/Buttons/GameOptionsButton.tsx @@ -5,7 +5,9 @@ import { MenuAction, MenuView } from '@react-native-menu/menu'; import { SymbolView } from 'expo-symbols'; import { StyleSheet, View, Text, Platform } from 'react-native'; +import { setGameInteractionType } from '../../../redux/GamesSlice'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; +import { selectInteractionType } from '../../../redux/selectors'; import { toggleHomeFullscreen, setInteractionType, markFeatureNotificationSeen } from '../../../redux/SettingsSlice'; import { logEvent } from '../../Analytics'; import { FEATURE_DIAL_GESTURE } from '../../constants'; @@ -22,7 +24,7 @@ const GameOptionsButton: React.FunctionComponent = () => { const { setMenuOpen } = useMenuOpen(); const currentGameId = useAppSelector(state => state.settings.currentGameId); - const interactionType = useAppSelector(state => state.settings.interactionType); + const interactionType = useAppSelector(state => selectInteractionType(state, currentGameId)); const fullscreen = useAppSelector(state => state.settings.home_fullscreen); const installId = useAppSelector(state => state.settings.installId); const addendOne = useAppSelector(state => state.settings.addendOne); @@ -114,19 +116,26 @@ const GameOptionsButton: React.FunctionComponent = () => { }, ]; + // Set the gesture for the current game, and update the global default so + // new games inherit the most recently chosen gesture. + const applyInteractionType = (type: InteractionType, eventName: string) => { + if (currentGameId) { + dispatch(setGameInteractionType({ gameId: currentGameId, interactionType: type })); + } + dispatch(setInteractionType(type)); + logEvent('interaction_type', { interactionType: eventName, gameId: currentGameId }); + }; + const handleAction = (event: string) => { switch (event) { case 'swipe': - dispatch(setInteractionType(InteractionType.SwipeVertical)); - logEvent('interaction_type', { interactionType: 'swipe_vertical', gameId: currentGameId }); + applyInteractionType(InteractionType.SwipeVertical, 'swipe_vertical'); break; case 'tap': - dispatch(setInteractionType(InteractionType.HalfTap)); - logEvent('interaction_type', { interactionType: 'half_tap', gameId: currentGameId }); + applyInteractionType(InteractionType.HalfTap, 'half_tap'); break; case 'dial': - dispatch(setInteractionType(InteractionType.Dial)); - logEvent('interaction_type', { interactionType: 'radial_gesture', gameId: currentGameId }); + applyInteractionType(InteractionType.Dial, 'radial_gesture'); break; case 'point-values': gameSheetRef?.current?.snapToIndex(0); diff --git a/src/components/Sheets/PointValuesSheet.tsx b/src/components/Sheets/PointValuesSheet.tsx index bc4bebd1..7109f611 100644 --- a/src/components/Sheets/PointValuesSheet.tsx +++ b/src/components/Sheets/PointValuesSheet.tsx @@ -11,6 +11,7 @@ import WheelPickerFeedback from '@quidone/react-native-wheel-picker-feedback'; import { Platform, StyleSheet, Text, View } from 'react-native'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; +import { selectInteractionType } from '../../../redux/selectors'; import { setAddendOne, setAddendTwo, setMultiplier } from '../../../redux/SettingsSlice'; import { logEvent } from '../../Analytics'; import { useTheme } from '../../theme'; @@ -27,7 +28,7 @@ const PointValuesSheet: React.FunctionComponent = () => { const theme = useTheme(); const reduxAddendOne = useAppSelector((state) => state.settings.addendOne); const reduxAddendTwo = useAppSelector((state) => state.settings.addendTwo); - const interactionType = useAppSelector((state) => state.settings.interactionType); + const interactionType = useAppSelector((state) => selectInteractionType(state, state.settings.currentGameId)); const dispatch = useAppDispatch(); diff --git a/src/hooks/useGestureHint.test.ts b/src/hooks/useGestureHint.test.ts index 5954ee42..fe96a239 100644 --- a/src/hooks/useGestureHint.test.ts +++ b/src/hooks/useGestureHint.test.ts @@ -4,7 +4,7 @@ import { configureStore } from '@reduxjs/toolkit'; import { act, renderHook } from '@testing-library/react-native'; import { Provider } from 'react-redux'; -import gamesReducer from '../../redux/GamesSlice'; +import gamesReducer, { setGameInteractionType } from '../../redux/GamesSlice'; import playersReducer, { playerRoundScoreSet } from '../../redux/PlayersSlice'; import settingsReducer, { setInteractionType } from '../../redux/SettingsSlice'; import { InteractionType } from '../components/Interactions/InteractionType'; @@ -68,6 +68,16 @@ describe('useGestureHint', () => { expect(result.current).toBe(true); }); + // req 3 (per-game): changing the game's own gesture re-enables the hint + it('re-shows hint when the per-game gesture type changes', () => { + const store = createStore([5]); // starts dismissed + const { result } = renderHook(() => useGestureHint(), { wrapper: wrap(store) }); + expect(result.current).toBe(false); + + act(() => { store.dispatch(setGameInteractionType({ gameId: 'game-1', interactionType: InteractionType.HalfTap })); }); + expect(result.current).toBe(true); + }); + // req 4: score after gesture change → hide again it('hides hint again after scoring with the new gesture', () => { const store = createStore([5]); // starts dismissed diff --git a/src/hooks/useGestureHint.ts b/src/hooks/useGestureHint.ts index 689bae32..7db75383 100644 --- a/src/hooks/useGestureHint.ts +++ b/src/hooks/useGestureHint.ts @@ -4,7 +4,7 @@ import { useAppSelector } from '../../redux/hooks'; import { selectCurrentGame, selectInteractionType } from '../../redux/selectors'; export function useGestureHint(): boolean { - const interactionType = useAppSelector(selectInteractionType); + const interactionType = useAppSelector(state => selectInteractionType(state, state.settings.currentGameId)); const gameLocked = useAppSelector(state => selectCurrentGame(state)?.locked ?? false); const fingerprint = useAppSelector(state => { diff --git a/src/screens/GameScreen.tsx b/src/screens/GameScreen.tsx index 15423198..4fa3b8f4 100644 --- a/src/screens/GameScreen.tsx +++ b/src/screens/GameScreen.tsx @@ -30,7 +30,7 @@ function useKeepScreenAwake(active: boolean): void { const GameScreen: React.FunctionComponent = () => { const currentGameId = useAppSelector(state => state.settings.currentGameId); const keepScreenAwake = useAppSelector(state => state.settings.keepScreenAwake); - const interactionType = useAppSelector(selectInteractionType); + const interactionType = useAppSelector(state => selectInteractionType(state, currentGameId)); const headerHeight = useHeaderHeight(); const showHint = useGestureHint(); useKeepScreenAwake(keepScreenAwake); From 0ae9f73f00b2d6446381df98ac7679c44c93bac8 Mon Sep 17 00:00:00 2001 From: Justin Wyne <1986068+wyne@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:56:22 -0400 Subject: [PATCH 2/2] feat: drive gesture hint from last-used gesture instead of per-game scores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hint previously showed on every new game with no scores and re-showed on every gesture switch, nagging experienced users — especially now that gestures are per-game. Replace the per-game score "fingerprint" logic with a persisted, global "last-used gesture": show the hint only when the active gesture differs from the one the user most recently used. - Add persisted `lastUsedInteractionType` to SettingsSlice with a `setLastUsedInteractionType` setter that no-ops when unchanged (so the per-score dispatch from interaction components doesn't churn state) - store.ts: whitelist the field, bump settings persist version 3 -> 4, and add migration 3 seeding it from the existing global gesture so returning users aren't shown the hint for a gesture they already use; fresh installs start undefined and see the hint until first use - Simplify useGestureHint to `!gameLocked && currentGesture !== lastUsed`, reusing the per-game selectInteractionType - Mark a gesture "used" at the three score-commit chokepoints (HalfTap, Swipe, and the Dial deferred-flush), updating only on use — never on a gesture change — so switching gestures re-shows the hint until used once - Rewrite useGestureHint tests for the new model; add SettingsSlice setter tests (set + no-op guard) Co-Authored-By: Claude Opus 4.8 --- redux/SettingsSlice.test.ts | 17 ++++ redux/SettingsSlice.ts | 11 +++ redux/store.ts | 8 +- .../Interactions/Dial/DialOverlay.tsx | 3 + .../HalfTap/HalfTileTouchSurface.tsx | 3 + src/components/Interactions/Swipe/Swipe.tsx | 3 + src/hooks/useGestureHint.test.ts | 98 ++++++++----------- src/hooks/useGestureHint.ts | 40 ++------ 8 files changed, 94 insertions(+), 89 deletions(-) diff --git a/redux/SettingsSlice.test.ts b/redux/SettingsSlice.test.ts index 4c4b139a..d5117c40 100644 --- a/redux/SettingsSlice.test.ts +++ b/redux/SettingsSlice.test.ts @@ -1,8 +1,11 @@ import { configureStore } from '@reduxjs/toolkit'; import { Store } from 'redux'; +import { InteractionType } from '../src/components/Interactions/InteractionType'; + import settingsReducer, { setCurrentGameId, + setLastUsedInteractionType, setMultiplier, SettingsState } from './SettingsSlice'; @@ -26,4 +29,18 @@ describe('settings reducer', () => { expect(store.getState().multiplier).toEqual(multiplier); }); + it('should handle setLastUsedInteractionType', () => { + expect(store.getState().lastUsedInteractionType).toBeUndefined(); + store.dispatch(setLastUsedInteractionType(InteractionType.Dial)); + expect(store.getState().lastUsedInteractionType).toEqual(InteractionType.Dial); + }); + + it('no-ops setLastUsedInteractionType when the value is unchanged', () => { + store.dispatch(setLastUsedInteractionType(InteractionType.HalfTap)); + const before = store.getState(); + store.dispatch(setLastUsedInteractionType(InteractionType.HalfTap)); + // Equality guard means the state object reference is unchanged. + expect(store.getState()).toBe(before); + }); + }); diff --git a/redux/SettingsSlice.ts b/redux/SettingsSlice.ts index 2f2e21cb..f02befb4 100644 --- a/redux/SettingsSlice.ts +++ b/redux/SettingsSlice.ts @@ -11,6 +11,9 @@ export interface SettingsState { showPointParticles: boolean; showPlayerIndex: boolean; interactionType: InteractionType; + // The gesture the user most recently *used* (committed a score with). + // Drives the gesture hint: undefined means "never used a gesture" (new user). + lastUsedInteractionType?: InteractionType; lastStoreReviewPrompt: number; devMenuEnabled?: boolean; appOpens: number; @@ -30,6 +33,7 @@ export const initialState: SettingsState = { showPointParticles: false, showPlayerIndex: false, interactionType: InteractionType.SwipeVertical, + lastUsedInteractionType: undefined, lastStoreReviewPrompt: 0, appOpens: 0, installId: undefined, @@ -68,6 +72,12 @@ const settingsSlice = createSlice({ setInteractionType(state, action: PayloadAction) { state.interactionType = action.payload; }, + setLastUsedInteractionType(state, action: PayloadAction) { + // Guard so the per-score dispatch from interaction components is a + // no-op once already set — no state change means no re-render. + if (state.lastUsedInteractionType === action.payload) return; + state.lastUsedInteractionType = action.payload; + }, setLastStoreReviewPrompt(state, action: PayloadAction) { state.lastStoreReviewPrompt = action.payload; }, @@ -115,6 +125,7 @@ export const { toggleShowPointParticles, toggleShowPlayerIndex, setInteractionType, + setLastUsedInteractionType, setLastStoreReviewPrompt, toggleDevMenuEnabled, increaseAppOpens, diff --git a/redux/store.ts b/redux/store.ts index afa0c193..6cecc8c4 100644 --- a/redux/store.ts +++ b/redux/store.ts @@ -23,11 +23,16 @@ const settingsMigrations: any = { const interactionType = stored && validTypes.includes(stored) ? stored : 'swipe-vertical'; return { ...state, interactionType }; }, + 3: (state: Record | undefined) => { + // Seed last-used gesture from the existing global gesture so returning + // users aren't shown the hint for a gesture they already use. + return { ...state, lastUsedInteractionType: state?.interactionType }; + }, }; const settingsPersistConfig = { key: 'settings', - version: 3, + version: 4, storage: AsyncStorage, migrate: createMigrate(settingsMigrations), whitelist: [ @@ -40,6 +45,7 @@ const settingsPersistConfig = { 'showPointParticles', 'showPlayerIndex', 'interactionType', + 'lastUsedInteractionType', 'lastStoreReviewPrompt', 'devMenuEnabled', 'appOpens', diff --git a/src/components/Interactions/Dial/DialOverlay.tsx b/src/components/Interactions/Dial/DialOverlay.tsx index b9b8b7f2..a17e1d93 100644 --- a/src/components/Interactions/Dial/DialOverlay.tsx +++ b/src/components/Interactions/Dial/DialOverlay.tsx @@ -17,7 +17,9 @@ import Animated, { import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { playerRoundScoreSet, selectPlayerById, selectPlayerRoundStats } from '../../../../redux/PlayersSlice'; import { selectCurrentGame } from '../../../../redux/selectors'; +import { setLastUsedInteractionType } from '../../../../redux/SettingsSlice'; import { useMenuOpen } from '../../MenuOpenContext'; +import { InteractionType } from '../InteractionType'; import DialControl from './DialControl'; @@ -127,6 +129,7 @@ const PlayerDialPage: React.FC = ({ const handleChange = useCallback((v: number) => { dispatch(playerRoundScoreSet(playerId, currentRoundIndex, v)); + dispatch(setLastUsedInteractionType(InteractionType.Dial)); }, [dispatch, playerId, currentRoundIndex]); const isDismissing = useSharedValue(false); diff --git a/src/components/Interactions/HalfTap/HalfTileTouchSurface.tsx b/src/components/Interactions/HalfTap/HalfTileTouchSurface.tsx index dce78d82..bb3eaa7e 100644 --- a/src/components/Interactions/HalfTap/HalfTileTouchSurface.tsx +++ b/src/components/Interactions/HalfTap/HalfTileTouchSurface.tsx @@ -6,9 +6,11 @@ import { StyleSheet, Text, TouchableHighlight, View } from 'react-native'; import { selectGameById } from '../../../../redux/GamesSlice'; import { useAppDispatch, useAppSelector, useAppStore } from '../../../../redux/hooks'; import { playerRoundScoreIncrement } from '../../../../redux/PlayersSlice'; +import { setLastUsedInteractionType } from '../../../../redux/SettingsSlice'; import { logEvent } from '../../../Analytics'; import { useMenuOpen } from '../../MenuOpenContext'; import { ScoreParticle } from '../../PlayerTiles/AdditionTile/ScoreParticle'; +import { InteractionType } from '../InteractionType'; type ScoreParticleProps = { key: string; @@ -85,6 +87,7 @@ export const HalfTileTouchSurface: React.FunctionComponent = ( interaction: 'half-tap', }); dispatch(playerRoundScoreIncrement(playerId, currentRoundIndex, scoreType == 'increment' ? addend : -addend)); + dispatch(setLastUsedInteractionType(InteractionType.HalfTap)); }; return ( diff --git a/src/components/Interactions/Swipe/Swipe.tsx b/src/components/Interactions/Swipe/Swipe.tsx index c854fcff..22e1cecd 100644 --- a/src/components/Interactions/Swipe/Swipe.tsx +++ b/src/components/Interactions/Swipe/Swipe.tsx @@ -8,8 +8,10 @@ import ReAnimated, { runOnJS, useAnimatedReaction, useAnimatedStyle, useSharedVa import { selectGameById } from '../../../../redux/GamesSlice'; import { useAppDispatch, useAppSelector, useAppStore } from '../../../../redux/hooks'; import { playerRoundScoreIncrement } from '../../../../redux/PlayersSlice'; +import { setLastUsedInteractionType } from '../../../../redux/SettingsSlice'; import { logEvent } from '../../../Analytics'; import { useMenuOpen } from '../../MenuOpenContext'; +import { InteractionType } from '../InteractionType'; interface HalfTapProps { children: React.ReactNode; @@ -211,6 +213,7 @@ const SwipeVertical: React.FC = ({ } dispatch(playerRoundScoreIncrement(playerId, currentRoundIndex, scoreDelta)); + dispatch(setLastUsedInteractionType(InteractionType.SwipeVertical)); }; useAnimatedReaction( diff --git a/src/hooks/useGestureHint.test.ts b/src/hooks/useGestureHint.test.ts index fe96a239..a8149386 100644 --- a/src/hooks/useGestureHint.test.ts +++ b/src/hooks/useGestureHint.test.ts @@ -4,26 +4,43 @@ import { configureStore } from '@reduxjs/toolkit'; import { act, renderHook } from '@testing-library/react-native'; import { Provider } from 'react-redux'; -import gamesReducer, { setGameInteractionType } from '../../redux/GamesSlice'; -import playersReducer, { playerRoundScoreSet } from '../../redux/PlayersSlice'; -import settingsReducer, { setInteractionType } from '../../redux/SettingsSlice'; +import gamesReducer from '../../redux/GamesSlice'; +import playersReducer from '../../redux/PlayersSlice'; +import settingsReducer, { setLastUsedInteractionType } from '../../redux/SettingsSlice'; import { InteractionType } from '../components/Interactions/InteractionType'; import { useGestureHint } from './useGestureHint'; -const createStore = (scores: number[] = [0]) => +interface StoreOpts { + /** Global gesture (the per-game fallback). Resolves the "current" gesture. */ + interactionType?: InteractionType; + /** Per-game gesture override on game-1. */ + gameInteractionType?: InteractionType; + /** The gesture the user last *used*. undefined = new user. */ + lastUsed?: InteractionType; + locked?: boolean; +} + +const createStore = (opts: StoreOpts = {}) => configureStore({ reducer: { settings: settingsReducer, games: gamesReducer, players: playersReducer }, preloadedState: { - settings: { currentGameId: 'game-1', interactionType: InteractionType.SwipeVertical }, + settings: { + currentGameId: 'game-1', + interactionType: opts.interactionType ?? InteractionType.SwipeVertical, + lastUsedInteractionType: opts.lastUsed, + }, games: { entities: { - 'game-1': { id: 'game-1', playerIds: ['p1'], dateCreated: 0, roundCurrent: 0, roundTotal: 1, locked: false }, + 'game-1': { + id: 'game-1', playerIds: ['p1'], dateCreated: 0, roundCurrent: 0, roundTotal: 1, + locked: opts.locked ?? false, interactionType: opts.gameInteractionType, + }, }, ids: ['game-1'], }, players: { - entities: { p1: { id: 'p1', playerName: 'P1', scores } }, + entities: { p1: { id: 'p1', playerName: 'P1', scores: [0] } }, ids: ['p1'], }, } as Parameters[0]['preloadedState'], @@ -34,76 +51,47 @@ const wrap = (store: ReturnType) => React.createElement(Provider, { store, children }); describe('useGestureHint', () => { - // req 1: new game with no scores → show hint - it('shows hint on new game with zero scores', () => { - const store = createStore([0]); + // New user: never used a gesture → show the hint. + it('shows hint when there is no last-used gesture', () => { + const store = createStore({ lastUsed: undefined }); const { result } = renderHook(() => useGestureHint(), { wrapper: wrap(store) }); expect(result.current).toBe(true); }); - // req 5: reopen game with existing scores → hide hint immediately (no flash) - it('hides hint immediately when game already has scores', () => { - const store = createStore([5]); + // The current gesture has already been used → no hint (covers reopen with scores). + it('hides hint when the current gesture matches the last-used gesture', () => { + const store = createStore({ interactionType: InteractionType.SwipeVertical, lastUsed: InteractionType.SwipeVertical }); const { result } = renderHook(() => useGestureHint(), { wrapper: wrap(store) }); expect(result.current).toBe(false); }); - // req 2: score change → hide hint - it('hides hint when a score is added', () => { - const store = createStore([0]); + // Active gesture differs from the last-used one (gesture switched) → show hint. + it('shows hint when the current gesture differs from the last-used gesture', () => { + const store = createStore({ interactionType: InteractionType.HalfTap, lastUsed: InteractionType.SwipeVertical }); const { result } = renderHook(() => useGestureHint(), { wrapper: wrap(store) }); expect(result.current).toBe(true); - - act(() => { store.dispatch(playerRoundScoreSet('p1', 0, 5)); }); - expect(result.current).toBe(false); }); - // req 3: gesture change → re-enable hint - it('re-shows hint when gesture type changes', () => { - const store = createStore([5]); // starts dismissed + // Using the gesture (marking it last-used) dismisses the hint. + it('hides hint once the current gesture is used', () => { + const store = createStore({ interactionType: InteractionType.HalfTap, lastUsed: InteractionType.SwipeVertical }); const { result } = renderHook(() => useGestureHint(), { wrapper: wrap(store) }); - expect(result.current).toBe(false); - - act(() => { store.dispatch(setInteractionType(InteractionType.HalfTap)); }); expect(result.current).toBe(true); - }); - // req 3 (per-game): changing the game's own gesture re-enables the hint - it('re-shows hint when the per-game gesture type changes', () => { - const store = createStore([5]); // starts dismissed - const { result } = renderHook(() => useGestureHint(), { wrapper: wrap(store) }); + act(() => { store.dispatch(setLastUsedInteractionType(InteractionType.HalfTap)); }); expect(result.current).toBe(false); - - act(() => { store.dispatch(setGameInteractionType({ gameId: 'game-1', interactionType: InteractionType.HalfTap })); }); - expect(result.current).toBe(true); }); - // req 4: score after gesture change → hide again - it('hides hint again after scoring with the new gesture', () => { - const store = createStore([5]); // starts dismissed + // Opening a game whose per-game gesture differs from the last-used one shows the + // hint — the "this game's mode is different" cue. + it('shows hint when a game uses a per-game gesture the user has not last used', () => { + const store = createStore({ lastUsed: InteractionType.SwipeVertical, gameInteractionType: InteractionType.Dial }); const { result } = renderHook(() => useGestureHint(), { wrapper: wrap(store) }); - - act(() => { store.dispatch(setInteractionType(InteractionType.HalfTap)); }); expect(result.current).toBe(true); - - act(() => { store.dispatch(playerRoundScoreSet('p1', 0, 10)); }); // fingerprint increases 5→10 - expect(result.current).toBe(false); }); - it('hides hint when the game is locked', () => { - const store = configureStore({ - reducer: { settings: settingsReducer, games: gamesReducer, players: playersReducer }, - preloadedState: { - settings: { currentGameId: 'game-1', interactionType: InteractionType.SwipeVertical }, - games: { - entities: { - 'game-1': { id: 'game-1', playerIds: ['p1'], dateCreated: 0, roundCurrent: 0, roundTotal: 1, locked: true }, - }, - ids: ['game-1'], - }, - players: { entities: { p1: { id: 'p1', playerName: 'P1', scores: [0] } }, ids: ['p1'] }, - } as Parameters[0]['preloadedState'], - }); + it('hides hint when the game is locked even if gestures differ', () => { + const store = createStore({ interactionType: InteractionType.HalfTap, lastUsed: InteractionType.SwipeVertical, locked: true }); const { result } = renderHook(() => useGestureHint(), { wrapper: wrap(store) }); expect(result.current).toBe(false); }); diff --git a/src/hooks/useGestureHint.ts b/src/hooks/useGestureHint.ts index 7db75383..98a05d5a 100644 --- a/src/hooks/useGestureHint.ts +++ b/src/hooks/useGestureHint.ts @@ -1,41 +1,15 @@ -import { useEffect, useRef, useState } from 'react'; - import { useAppSelector } from '../../redux/hooks'; import { selectCurrentGame, selectInteractionType } from '../../redux/selectors'; +// Show the gesture hint when the active gesture differs from the one the user +// most recently used. `lastUsedInteractionType` is undefined for a brand-new +// user (hint shows until first use) and is updated only when a gesture is used +// (a score is committed) — never on a gesture change — so switching gestures +// re-shows the hint until the new gesture is used once. Locked games never show it. export function useGestureHint(): boolean { const interactionType = useAppSelector(state => selectInteractionType(state, state.settings.currentGameId)); + const lastUsed = useAppSelector(state => state.settings.lastUsedInteractionType); const gameLocked = useAppSelector(state => selectCurrentGame(state)?.locked ?? false); - const fingerprint = useAppSelector(state => { - const game = selectCurrentGame(state); - if (!game) return 0; - return game.playerIds.reduce((sum, id) => { - const scores = state.players.entities[id]?.scores ?? []; - return sum + scores.reduce((s, v) => s + Math.abs(v), 0); - }, 0); - }); - - // Initialize false when scores exist — no flash on reopen with existing scores. - const [showHint, setShowHint] = useState(() => fingerprint === 0); - const isFirstRun = useRef(true); - - // Re-enable hint on gesture switch. Skip on mount so initialization holds. - useEffect(() => { - if (isFirstRun.current) { - isFirstRun.current = false; - return; - } - setShowHint(true); - }, [interactionType]); - - // Dismiss hint whenever scores exist. - // interactionType intentionally omitted: gesture switches must not re-trigger dismissal. - useEffect(() => { - if (fingerprint > 0) { - setShowHint(false); - } - }, [fingerprint]); - - return !gameLocked && showHint; + return !gameLocked && interactionType !== lastUsed; }