Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions redux/GamesSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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({
Expand Down Expand Up @@ -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<Record<string, GameState>>) {
gamesAdapter.setAll(state, action.payload);
},
Expand Down Expand Up @@ -324,6 +339,7 @@ export const {
gameDelete,
setSortSelector,
reorderPlayers,
setGameInteractionType,
restoreAllGames,
} = gamesSlice.actions;

Expand Down
17 changes: 17 additions & 0 deletions redux/SettingsSlice.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
});

});
11 changes: 11 additions & 0 deletions redux/SettingsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,6 +33,7 @@ export const initialState: SettingsState = {
showPointParticles: false,
showPlayerIndex: false,
interactionType: InteractionType.SwipeVertical,
lastUsedInteractionType: undefined,
lastStoreReviewPrompt: 0,
appOpens: 0,
installId: undefined,
Expand Down Expand Up @@ -68,6 +72,12 @@ const settingsSlice = createSlice({
setInteractionType(state, action: PayloadAction<InteractionType>) {
state.interactionType = action.payload;
},
setLastUsedInteractionType(state, action: PayloadAction<InteractionType>) {
// 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<number>) {
state.lastStoreReviewPrompt = action.payload;
},
Expand Down Expand Up @@ -115,6 +125,7 @@ export const {
toggleShowPointParticles,
toggleShowPlayerIndex,
setInteractionType,
setLastUsedInteractionType,
setLastStoreReviewPrompt,
toggleDevMenuEnabled,
increaseAppOpens,
Expand Down
57 changes: 56 additions & 1 deletion redux/selectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
6 changes: 4 additions & 2 deletions redux/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion redux/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,16 @@ const settingsMigrations: any = {
const interactionType = stored && validTypes.includes(stored) ? stored : 'swipe-vertical';
return { ...state, interactionType };
},
3: (state: Record<string, unknown> | 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: [
Expand All @@ -40,6 +45,7 @@ const settingsPersistConfig = {
'showPointParticles',
'showPlayerIndex',
'interactionType',
'lastUsedInteractionType',
'lastStoreReviewPrompt',
'devMenuEnabled',
'appOpens',
Expand Down
4 changes: 3 additions & 1 deletion src/components/Boards/PlayerTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ const PlayerTile: React.FunctionComponent<Props> = 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 (
Expand Down
9 changes: 8 additions & 1 deletion src/components/Buttons/GameOptionsButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
Expand All @@ -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<typeof configureStore>[0]['preloadedState'],
});

Expand Down
23 changes: 16 additions & 7 deletions src/components/Buttons/GameOptionsButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/components/Interactions/Dial/DialOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -127,6 +129,7 @@ const PlayerDialPage: React.FC<PlayerDialPageProps> = ({

const handleChange = useCallback((v: number) => {
dispatch(playerRoundScoreSet(playerId, currentRoundIndex, v));
dispatch(setLastUsedInteractionType(InteractionType.Dial));
}, [dispatch, playerId, currentRoundIndex]);

const isDismissing = useSharedValue(false);
Expand Down
3 changes: 3 additions & 0 deletions src/components/Interactions/HalfTap/HalfTileTouchSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -85,6 +87,7 @@ export const HalfTileTouchSurface: React.FunctionComponent<Props> = (
interaction: 'half-tap',
});
dispatch(playerRoundScoreIncrement(playerId, currentRoundIndex, scoreType == 'increment' ? addend : -addend));
dispatch(setLastUsedInteractionType(InteractionType.HalfTap));
};

return (
Expand Down
3 changes: 3 additions & 0 deletions src/components/Interactions/Swipe/Swipe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -211,6 +213,7 @@ const SwipeVertical: React.FC<HalfTapProps> = ({
}

dispatch(playerRoundScoreIncrement(playerId, currentRoundIndex, scoreDelta));
dispatch(setLastUsedInteractionType(InteractionType.SwipeVertical));
};

useAnimatedReaction(
Expand Down
3 changes: 2 additions & 1 deletion src/components/Sheets/PointValuesSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand Down
Loading
Loading