Skip to content
Open
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
45 changes: 28 additions & 17 deletions src/components/Interactions/Swipe/Swipe.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,6 @@ jest.mock('react-native-reanimated', () => ({
__esModule: true,
useSharedValue: function (i: unknown) { return { value: i }; },
useAnimatedStyle: function (fn: () => unknown) { return fn(); },
useAnimatedReaction: function (_p: () => unknown, r: (c: unknown, p: unknown) => void) {
(globalThis as any).__react = r;
r(_p(), undefined);
},
runOnJS: function (fn: (...args: unknown[]) => unknown) { return fn; },
withTiming: function (_t: number) { return _t; },
default: { View: function () { return null; } },
Expand Down Expand Up @@ -56,7 +52,6 @@ jest.mock('react-native-gesture-handler', () => ({

beforeEach(() => {
(globalThis as any).__ph = {};
(globalThis as any).__react = undefined;
jest.useRealTimers();
});

Expand All @@ -78,11 +73,6 @@ const renderSwipe = ({ onRender }: { onRender?: (id: string) => void; } = {}) =>
return { dispatch, store };
};

const triggerReaction = (totalOffset: number, prevTotalOffset: number) => {
const react = (globalThis as any).__react;
if (react) react(totalOffset, prevTotalOffset);
};

describe('SwipeVertical', () => {
it('renders and captures gesture callbacks', () => {
renderSwipe();
Expand All @@ -98,7 +88,10 @@ describe('SwipeVertical', () => {

ph.onBegin();
ph.onUpdate({ translationY: -100 });
triggerReaction(100, 0);
expect(dispatch).not.toHaveBeenCalledWith(
playerRoundScoreIncrement('player-1', 0, 2)
);

act(() => {
ph.onEnd({ translationY: -100 });
ph.onFinalize();
Expand All @@ -115,7 +108,6 @@ describe('SwipeVertical', () => {

ph.onBegin();
ph.onUpdate({ translationY: 100 });
triggerReaction(-100, 0);
act(() => {
ph.onEnd({ translationY: 100 });
ph.onFinalize();
Expand All @@ -132,7 +124,6 @@ describe('SwipeVertical', () => {

ph.onBegin();
ph.onUpdate({ translationY: -250 });
triggerReaction(250, 0);
act(() => {
ph.onEnd({ translationY: -250 });
ph.onFinalize();
Expand All @@ -152,7 +143,6 @@ describe('SwipeVertical', () => {
act(() => { jest.advanceTimersByTime(401); });

ph.onUpdate({ translationY: -100 });
triggerReaction(100, 0);
act(() => {
ph.onEnd({ translationY: -100 });
ph.onFinalize();
Expand All @@ -170,12 +160,10 @@ describe('SwipeVertical', () => {

ph.onBegin();
ph.onUpdate({ translationY: -2 });
triggerReaction(2, 0);

act(() => { jest.advanceTimersByTime(401); });

ph.onUpdate({ translationY: -100 });
triggerReaction(100, 2);
act(() => {
ph.onEnd({ translationY: -100 });
ph.onFinalize();
Expand All @@ -186,6 +174,30 @@ describe('SwipeVertical', () => {
);
});

it('flushes only once when onEnd and onFinalize both run', () => {
const { dispatch } = renderSwipe();
const ph = (globalThis as any).__ph;
const expectedAction = playerRoundScoreIncrement('player-1', 0, 2);

ph.onBegin();
ph.onUpdate({ translationY: -100 });

act(() => {
ph.onEnd({ translationY: -100 });
ph.onFinalize();
});

expect(dispatch.mock.calls.filter(([action]) => {
const dispatchedAction = action as typeof expectedAction;
return (
dispatchedAction.type === expectedAction.type &&
dispatchedAction.payload === expectedAction.payload &&
dispatchedAction.meta.round === expectedAction.meta.round &&
dispatchedAction.meta.multiplier === expectedAction.meta.multiplier
);
})).toHaveLength(1);
});

it('does not re-render solely because the current round changes', () => {
const onRender = jest.fn();
const { store } = renderSwipe({ onRender });
Expand All @@ -211,7 +223,6 @@ describe('SwipeVertical', () => {

ph.onBegin();
ph.onUpdate({ translationY: -100 });
triggerReaction(100, 0);
act(() => {
ph.onEnd({ translationY: -100 });
ph.onFinalize();
Expand Down
132 changes: 84 additions & 48 deletions src/components/Interactions/Swipe/Swipe.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

import * as Haptics from 'expo-haptics';
import { Animated, StyleSheet, Text, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import ReAnimated, { runOnJS, useAnimatedReaction, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import ReAnimated, { runOnJS, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';

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 { OptimisticScoreContext } from '../../PlayerTiles/AdditionTile/OptimisticScoreContext';
import { InteractionType } from '../InteractionType';

interface HalfTapProps {
Expand Down Expand Up @@ -53,6 +54,19 @@ const SwipeVertical: React.FC<HalfTapProps> = ({

const addendOne = useAppSelector(state => state.settings.addendOne);
const addendTwo = useAppSelector(state => state.settings.addendTwo);
const svAddendOne = useSharedValue(addendOne);
const svAddendTwo = useSharedValue(addendTwo);
const svRoundScore = useSharedValue(0);
const svRoundTotalScore = useSharedValue(0);
const optimisticScores = useMemo(() => ({
currentRoundScore: svRoundScore,
currentRoundTotalScore: svRoundTotalScore,
}), [svRoundScore, svRoundTotalScore]);

useEffect(() => {
svAddendOne.value = addendOne;
svAddendTwo.value = addendTwo;
}, [addendOne, addendTwo]);

//#endregion

Expand Down Expand Up @@ -141,53 +155,111 @@ const SwipeVertical: React.FC<HalfTapProps> = ({

//#region Gesture handling

const totalOffset = useSharedValue<number | null>(0);
const panY = useSharedValue(0);
const svStartRoundScore = useSharedValue(0);
const svStartRoundTotalScore = useSharedValue(0);
const svPendingDelta = useSharedValue(0);
const svLastNotches = useSharedValue(0);
const svDidFlush = useSharedValue(true);

const { menuOpen } = useMenuOpen();

const getCurrentRoundIndex = useCallback(() => {
const getCurrentRoundStats = useCallback(() => {
const state = store.getState();
const currentGameId = state.settings.currentGameId;
return currentGameId ? selectGameById(state, currentGameId)?.roundCurrent ?? 0 : 0;
}, [store]);
const currentGame = currentGameId ? selectGameById(state, currentGameId) : undefined;
const currentRoundIndex = currentGame?.roundCurrent ?? 0;
const scores: number[] = state.players.entities[playerId]?.scores ?? [];
const currentRoundScore = scores[currentRoundIndex] ?? 0;
const previousTotal = scores.reduce(
(sum, s, i) => (i < currentRoundIndex ? sum + (s || 0) : sum), 0
);

return {
currentRoundIndex,
currentRoundScore,
currentRoundTotalScore: previousTotal + currentRoundScore,
};
}, [playerId, store]);

const endGesture = useCallback((translationY: number) => {
if (menuOpen) return;
logEvent('score_change', {
player_index: index,
game_id: currentGameId,
addend: secondaryHold ? addendTwo : addendOne,
round: getCurrentRoundIndex(),
round: getCurrentRoundStats().currentRoundIndex,
type: translationY > 0 ? 'decrement' : 'increment',
power_hold: secondaryHold,
notches: -Math.round((translationY || 0) / notchSize),
interaction: 'swipe-vertical',
});
secondaryHoldStop();
}, [index, currentGameId, secondaryHold, addendOne, addendTwo, getCurrentRoundIndex, menuOpen]);
}, [index, currentGameId, secondaryHold, addendOne, addendTwo, getCurrentRoundStats, menuOpen]);

const flushPendingChange = useCallback((delta: number) => {
if (delta === 0) return;
if (menuOpen) return;

dispatch(playerRoundScoreIncrement(playerId, getCurrentRoundStats().currentRoundIndex, delta));
dispatch(setLastUsedInteractionType(InteractionType.SwipeVertical));
}, [dispatch, playerId, getCurrentRoundStats, menuOpen]);

const bumpFeedback = useCallback((secondary: boolean) => {
if (secondary) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
} else {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
}, []);

const panGesture = Gesture.Pan()
.enabled(!currentGameLocked && !menuOpen)
.minDistance(0)
.onBegin(() => {
svStartRoundScore.value = svRoundScore.value;
svStartRoundTotalScore.value = svRoundTotalScore.value;
svPendingDelta.value = 0;
svLastNotches.value = 0;
svDidFlush.value = false;
runOnJS(secondaryHoldStart)();
})
.onUpdate((event) => {
const y = event.translationY;
panY.value = y;
totalOffset.value = -y;

if (isSecondaryHoldActive.value == false && Math.abs(y) > 1) {
runOnJS(secondaryHoldStop)();
}

const notches = Math.round((-y || 0) / notchSize);
if (notches === svLastNotches.value) return;

svLastNotches.value = notches;
const secondaryActive = isSecondaryHoldActive.value;
const scoreDelta = notches * (secondaryActive ? svAddendTwo.value : svAddendOne.value);
svPendingDelta.value = scoreDelta;
svRoundScore.value = svStartRoundScore.value + scoreDelta;
svRoundTotalScore.value = svStartRoundTotalScore.value + scoreDelta;
runOnJS(bumpFeedback)(secondaryActive);
})
.onEnd((event) => {
totalOffset.value = null;
if (!svDidFlush.value) {
svDidFlush.value = true;
if (svPendingDelta.value !== 0) {
runOnJS(flushPendingChange)(svPendingDelta.value);
}
}
panY.value = withTiming(0, { duration: 200 });
runOnJS(endGesture)(event.translationY);
})
.onFinalize(() => {
if (!svDidFlush.value) {
svDidFlush.value = true;
if (svPendingDelta.value !== 0) {
runOnJS(flushPendingChange)(svPendingDelta.value);
}
}
runOnJS(secondaryHoldStop)();
});

Expand All @@ -197,44 +269,8 @@ const SwipeVertical: React.FC<HalfTapProps> = ({

//#endregion

//#region Helpers

const scoreChangeHandler = (value: number) => {
if (Math.abs(value) == 0) return;
if (menuOpen) return;

const scoreDelta = value * (secondaryHold ? addendTwo : addendOne);
const currentRoundIndex = getCurrentRoundIndex();

if (secondaryHold) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
} else {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}

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

useAnimatedReaction(
() => {
return totalOffset.value;
},
(currentValue, previousValue) => {
if (currentValue === null) return;

const c = Math.round((currentValue || 0) / notchSize);
const p = Math.round((previousValue || 0) / notchSize);
if (c - p !== 0) {
runOnJS(scoreChangeHandler)(c - p);
}
}
);

//#endregion

return (
<>
<OptimisticScoreContext.Provider value={optimisticScores}>
<Animated.View style={[
{
transform: [{
Expand Down Expand Up @@ -270,7 +306,7 @@ const SwipeVertical: React.FC<HalfTapProps> = ({
<View style={[styles.slider, styles.sliderBottom]} />
</ReAnimated.View>
</GestureDetector>
</>
</OptimisticScoreContext.Provider>
);
};

Expand Down
21 changes: 18 additions & 3 deletions src/components/PlayerTiles/AdditionTile/AdditionTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { selectGameById } from '../../../../redux/GamesSlice';
import { useAppSelector } from '../../../../redux/hooks';

import { calculateFontSize } from './Helpers';
import { OptimisticScoreContext } from './OptimisticScoreContext';
import ScoreAfter from './ScoreAfter';
import ScoreBefore from './ScoreBefore';
import ScoreRound from './ScoreRound';
Expand Down Expand Up @@ -60,6 +61,15 @@ const AdditionTile: React.FunctionComponent<Props> = ({
};
}, shallowEqual);

const optimisticScores = React.useContext(OptimisticScoreContext);

React.useLayoutEffect(() => {
if (optimisticScores == null) return;

optimisticScores.currentRoundScore.value = currentRoundScore;
optimisticScores.currentRoundTotalScore.value = currentRoundTotalScore;
}, [currentRoundScore, currentRoundTotalScore, optimisticScores]);

if (!hasCurrentGame) return null;
if (typeof playerName == 'undefined') return null;

Expand Down Expand Up @@ -99,13 +109,18 @@ const AdditionTile: React.FunctionComponent<Props> = ({

<Animated.View style={styles.scoreLineOne}>
<ScoreBefore containerWidth={containerShortEdge} currentRoundScore={currentRoundScore} currentRoundTotalScore={currentRoundTotalScore}
fontColor={fontColor} />
fontColor={fontColor}
optimisticCurrentRoundScore={optimisticScores?.currentRoundScore}
optimisticCurrentRoundTotalScore={optimisticScores?.currentRoundTotalScore} />
<ScoreRound containerWidth={containerShortEdge} currentRoundScore={currentRoundScore}
fontColor={fontColor} />
fontColor={fontColor}
optimisticCurrentRoundScore={optimisticScores?.currentRoundScore} />
</Animated.View>

<ScoreAfter containerWidth={containerShortEdge} currentRoundScore={currentRoundScore} currentRoundTotalScore={currentRoundTotalScore}
fontColor={fontColor} />
fontColor={fontColor}
optimisticCurrentRoundScore={optimisticScores?.currentRoundScore}
optimisticCurrentRoundTotalScore={optimisticScores?.currentRoundTotalScore} />
</Animated.View>
);
};
Expand Down
Loading
Loading