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
68 changes: 68 additions & 0 deletions mobile/src/design/components/ImpactHexagon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { act, renderHook } from '@testing-library/react-native';
import { impactHexagonLabel, liquidY, useCountUp } from './ImpactHexagon';

// size 108 on Acasă: H = 119, R = 52, corners at y 7.5 and 111.5, headroom 21
const TOP = 7.5;
const BOTTOM = 111.5;
const HEADROOM = 21;

describe('ImpactHexagon liquid level', () => {
it('hides the wave under the bottom corner when empty', () => {
// the crest (middle line − amp) is still at the bottom corner
expect(liquidY(0, TOP, BOTTOM, 7, HEADROOM) - 7).toBe(BOTTOM);
});

it('keeps the crest under the top corner when full, so the liquid still reads as liquid', () => {
const middle = liquidY(1, TOP, BOTTOM, 7, HEADROOM);
expect(middle - 7).toBeGreaterThan(TOP + 10);
});

it('42 aprobate + 1 respinsă (98%) still shows the crest below the top corner', () => {
const middle = liquidY(42 / 43, TOP, BOTTOM, 7, HEADROOM);
expect(middle - 7).toBeGreaterThan(TOP + 10);
expect(middle).toBeGreaterThan(liquidY(1, TOP, BOTTOM, 7, HEADROOM));
});

it('rises with the level and clamps outside 0–1', () => {
expect(liquidY(0.5, TOP, BOTTOM, 7, HEADROOM)).toBeLessThan(
liquidY(0.25, TOP, BOTTOM, 7, HEADROOM),
);
expect(liquidY(2, TOP, BOTTOM, 7, HEADROOM)).toBe(liquidY(1, TOP, BOTTOM, 7, HEADROOM));
expect(liquidY(-1, TOP, BOTTOM, 7, HEADROOM)).toBe(liquidY(0, TOP, BOTTOM, 7, HEADROOM));
});

it('labels the hours in Romanian', () => {
expect(impactHexagonLabel(42, 0, 1)).toBe('42 de ore aprobate, 1 oră respinsă');
expect(impactHexagonLabel(42, 5, 0)).toBe('42 de ore aprobate, 5 ore în așteptare');
});
});

describe('useCountUp', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

it('counts again from 0 when the replay key changes (each focus of Acasă)', async () => {
const hook = await renderHook(
({ replay }: { replay: number }) => useCountUp(42, false, replay),
{ initialProps: { replay: 1 } },
);
await act(async () => {
jest.advanceTimersByTime(1200);
});
expect(hook.result.current).toBe(42);
await hook.rerender({ replay: 2 });
await act(async () => {
jest.advanceTimersByTime(100);
});
expect(hook.result.current).toBeLessThan(42);
await act(async () => {
jest.advanceTimersByTime(1200);
});
expect(hook.result.current).toBe(42);
});

it('shows the number at once with Reduce Motion', async () => {
const hook = await renderHook(() => useCountUp(42, true, 1));
expect(hook.result.current).toBe(42);
});
});
89 changes: 65 additions & 24 deletions mobile/src/design/components/ImpactHexagon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,15 @@ export interface ImpactHexagonProps {

const FAST_MS = 2600;
const SLOW_MS = 3600;
// the prototype's wavelength: one crest every 120pt, whatever the size
const PERIOD = 120;
const FAST_AMP = 7;
const SLOW_AMP = 4;
// both waves are back where they started after this long, so the clock can wrap without a jump
const CLOCK_WRAP = 46800;
const COUNT_MS = 900;
// the dark half of the „în așteptare” stripes on the navy card (prototype pattern #012A40)
const STRIPE_BG_ON_NAVY = '#012A40';

/** Romanian count: „1 oră”, „5 ore”, „42 de ore” (20+ and round hundreds take „de”). */
const roHours = (n: number) => {
Expand Down Expand Up @@ -93,15 +99,43 @@ const waveLine = (width: number, period: number, amp: number) => {
const waveFill = (width: number, height: number, period: number, amp: number) =>
`${waveLine(width, period, amp)}L${width} ${height}L0 ${height}Z`;

/** 45° stripes, 4pt yellow every 8pt, covering width × height. */
/**
* The prototype's stripe pattern (8pt tile with a 4pt bar, rotated 45°): bars 4pt wide every 8pt
* measured across the stripes, so 4√2 wide every 8√2 along a horizontal line.
*/
const stripes = (width: number, height: number) => {
const pitch = 8 * Math.SQRT2;
const bar = 4 * Math.SQRT2;
let d = '';
for (let x = -height; x < width; x += 8) {
d += `M${x} 0L${x + 4} 0L${x + 4 + height} ${height}L${x + height} ${height}Z`;
for (let x = -height; x < width; x += pitch) {
d += `M${x.toFixed(2)} 0L${(x + bar).toFixed(2)} 0L${(x + bar + height).toFixed(2)} ${height}L${(
x + height
).toFixed(2)} ${height}Z`;
}
return d;
};

/**
* Where the middle of a wave sits (y, from the top of the canvas) for a fill level 0–1.
* 0 hides the wave under the bottom corner. 1 does NOT reach the top corner: the prototype maps
* the level straight onto the height, so real numbers like 42 aprobate + 1 respinsă (98%) cover
* the whole hexagon and it reads as a flat yellow shape. Here a full level stops `headroom`
* below the top corner, so the crest and its motion always stay visible under the outline.
*/
export const liquidY = (
level: number,
top: number,
bottom: number,
amp: number,
headroom: number,
) => {
'worklet';
const empty = bottom + amp;
const full = top + headroom;
const clamped = Math.min(1, Math.max(0, level));
return empty - clamped * (empty - full);
};

const svgPath = (d: string) => Skia.Path.MakeFromSVGString(d) ?? Skia.Path.Make();

/** Focus of the screen around the hexagon; always focused outside a navigator. */
Expand Down Expand Up @@ -134,22 +168,27 @@ export const ImpactHexagon = ({
}: ImpactHexagonProps) => {
const reduced = useReducedMotion();
const focused = useScreenFocused();
const onNavy = tone === 'onNavy';

const W = size;
const H = Math.round(size * 1.1);
const period = Math.round(size * 1.1);
const R = W / 2 - 2;
const period = PERIOD;
const bandWidth = W + period + 8;
// the hexagon's top and bottom corners, and the gap a full level keeps under the top one
const top = H / 2 - R;
const bottom = H / 2 + R;
const headroom = Math.round(R * 0.4);

const paths = useMemo(() => {
const hex = roundedHexPath(W / 2, H / 2, W / 2 - 2, W * 0.12);
const hex = roundedHexPath(W / 2, H / 2, R, W * 0.12);
return {
hex: svgPath(hex),
approved: svgPath(waveFill(bandWidth, H * 2, period, 7)),
pending: svgPath(waveFill(bandWidth, H * 2, period, 4)),
crest: svgPath(waveLine(bandWidth, period, 4)),
approved: svgPath(waveFill(bandWidth, H * 2, period, FAST_AMP)),
pending: svgPath(waveFill(bandWidth, H * 2, period, SLOW_AMP)),
stripes: svgPath(stripes(bandWidth, H * 2)),
};
}, [W, H, period, bandWidth]);
}, [W, H, R, period, bandWidth]);

// defensive: counters can arrive as numeric strings
const a = toHours(approved);
Expand Down Expand Up @@ -188,15 +227,15 @@ export const ImpactHexagon = ({

const approvedTransform = useDerivedValue(() => [
{ translateX: -((clock.value / FAST_MS) % 1) * period },
{ translateY: H * (1 - approvedLevel.value) - 5 },
// the wave path has its middle line at y = amp
{ translateY: liquidY(approvedLevel.value, top, bottom, FAST_AMP, headroom) - FAST_AMP },
]);
const pendingTransform = useDerivedValue(() => [
// counter-phase: the slow wave runs the other way
{ translateX: -(1 - ((clock.value / SLOW_MS) % 1)) * period },
{ translateY: H * (1 - pendingLevel.value) - 4 },
{ translateY: liquidY(pendingLevel.value, top, bottom, SLOW_AMP, headroom) - SLOW_AMP },
]);

const onNavy = tone === 'onNavy';
return (
<View
testID={testID}
Expand All @@ -208,19 +247,21 @@ export const ImpactHexagon = ({
<Canvas style={{ width: W, height: H }} pointerEvents="none">
<Path path={paths.hex} color={onNavy ? palette.navy3 : '#EAE5D8'} />
<Group clip={paths.hex}>
<Group transform={pendingTransform}>
<Group clip={paths.pending}>
<Rect x={0} y={0} width={bandWidth} height={H * 2} color={palette.white} />
<Path path={paths.stripes} color={palette.yellow} />
{/* no band without pending hours: its crest would show in the troughs of the liquid */}
{pendingShare > 0 && (
<Group transform={pendingTransform}>
<Group clip={paths.pending}>
<Rect
x={0}
y={0}
width={bandWidth}
height={H * 2}
color={onNavy ? STRIPE_BG_ON_NAVY : palette.white}
/>
<Path path={paths.stripes} color={onNavy ? palette.yellow : palette.navy} />
</Group>
</Group>
<Path
path={paths.crest}
color={palette.navy}
style="stroke"
strokeWidth={2}
strokeJoin="round"
/>
</Group>
)}
<Group transform={approvedTransform}>
<Path path={paths.approved} color={palette.yellow} />
</Group>
Expand Down
16 changes: 14 additions & 2 deletions mobile/src/screens/Home.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { RefreshControl, View, useWindowDimensions } from 'react-native';
import Animated, { useReducedMotion, useSharedValue } from 'react-native-reanimated';
import { useFocusEffect } from '@react-navigation/native';
Expand Down Expand Up @@ -70,6 +70,18 @@ const Home = ({ navigation }: any) => {
const orgSheet = useRef<BottomSheetModal>(null);
// each focus refills the hexagon and counts the hours again
const [focusCount, setFocusCount] = useState(0);
// switching to Acasă from another tab plays the staggered entrance again (prototype: each tab
// enters with its blocks 40 ms apart); coming back from a pushed screen does not
const [enterKey, setEnterKey] = useState(0);
useEffect(
() =>
navigation.addListener('tabPress', () => {
if (!navigation.isFocused()) {
setEnterKey((key) => key + 1);
}
}),
[navigation],
);

const { userProfile } = useUserProfile();
const { setUserProfile } = useStore();
Expand Down Expand Up @@ -222,7 +234,7 @@ const Home = ({ navigation }: any) => {
showsVerticalScrollIndicator={false}
testID="home"
>
<React.Fragment key={fontScale}>
<React.Fragment key={`${fontScale}-${enterKey}`}>
<Animated.View entering={entering()}>
<OrgRow
name={organization?.name}
Expand Down
Loading
Loading