diff --git a/mobile/src/design/components/ImpactHexagon.test.tsx b/mobile/src/design/components/ImpactHexagon.test.tsx new file mode 100644 index 00000000..5430b8f8 --- /dev/null +++ b/mobile/src/design/components/ImpactHexagon.test.tsx @@ -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); + }); +}); diff --git a/mobile/src/design/components/ImpactHexagon.tsx b/mobile/src/design/components/ImpactHexagon.tsx index 70f04888..c516dfbf 100644 --- a/mobile/src/design/components/ImpactHexagon.tsx +++ b/mobile/src/design/components/ImpactHexagon.tsx @@ -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) => { @@ -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. */ @@ -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); @@ -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 ( - - - - + {/* no band without pending hours: its crest would show in the troughs of the liquid */} + {pendingShare > 0 && ( + + + + + - - + )} diff --git a/mobile/src/screens/Home.tsx b/mobile/src/screens/Home.tsx index af80620b..f3691e61 100644 --- a/mobile/src/screens/Home.tsx +++ b/mobile/src/screens/Home.tsx @@ -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'; @@ -70,6 +70,18 @@ const Home = ({ navigation }: any) => { const orgSheet = useRef(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(); @@ -222,7 +234,7 @@ const Home = ({ navigation }: any) => { showsVerticalScrollIndicator={false} testID="home" > - + void; - style?: StyleProp; - /** style of the touch area around the scaled content (e.g. flex: 1 in a row) */ - outerStyle?: StyleProp; - children: ReactNode; - testID?: string; - disabled?: boolean; - hitSlop?: number; -} - -export const Press = ({ onPress, style, outerStyle, children, disabled, ...props }: PressProps) => { - const reduced = useReducedMotion(); - const pressed = useSharedValue(0); - const scale = useAnimatedStyle(() => ({ - transform: [{ scale: reduced ? 1 : interpolate(pressed.value, [0, 1], [1, 0.965]) }], - })); - return ( - { - pressed.value = springTo(1, 'snappy', reduced); - }} - onPressOut={() => { - pressed.value = springTo(0, 'snappy', reduced); - }} - > - {children} - - ); -}; - // --------------------------------------------------------------------------------------------- // Large title + compact glass bar. The large title scrolls with the content (as in the // prototype), shrinks and fades; the compact bar „Acasă” fades in after 48pt. @@ -146,7 +98,12 @@ export const LargeTitle = ({ title, scrollY }: { title: string; scrollY: SharedV }); return ( - + {title} @@ -170,11 +127,10 @@ export const OrgRow = ({ return ( {name ? ( - - + ) : ( )} - - + ); }; @@ -256,7 +212,7 @@ export const ImpactCard = ({ .filter(Boolean) .join(', '); return ( - {t('impact.overline')} - - - {`${shown}`} - - h - - - + + {`${shown}`} + {/* the small „h” (36pt) inherits the line of the number; AppText trims the line */} + + h + + {approved === 1 ? t('impact.label_singular') : t('impact.label')} @@ -316,7 +271,7 @@ export const ImpactCard = ({ )} - + ); }; @@ -341,7 +296,7 @@ export const TodoCard = ({ onPress: () => void; testID?: string; }) => ( - - + ); // --------------------------------------------------------------------------------------------- @@ -435,8 +390,9 @@ const EventSlide = ({ : { kind: 'ok', label: `${t('card.going')}` }; const audience = event.isPublic ? `${t('card.public')}` : `${t('card.members')}`; return ( - onPress(event.id)} + // every card as tall as the tallest one (the prototype's flex row stretches them) style={[styles.ecard, { width }]} accessibilityLabel={[event.name, when, event.location, event.organizationName, status?.label] .filter(Boolean) @@ -455,9 +411,7 @@ const EventSlide = ({ - - {event.name} - + {event.name} {!!event.location && } {!!event.organizationName && } @@ -476,7 +430,7 @@ const EventSlide = ({ {status && } - + ); }; @@ -493,7 +447,7 @@ const MetaLine = ({ }) => ( {Icon && } - + {text} @@ -586,7 +540,7 @@ export const MonthBento = ({ tiles }: { tiles: BentoTile[] }) => { {content} ) : ( - { testID={`home-stat-${tile.key}`} > {content} - + ); })} @@ -622,6 +576,7 @@ const styles = StyleSheet.create((theme) => ({ letterSpacing: -0.2, }, largeTitle: { marginTop: 14, marginBottom: 16, transformOrigin: 'left bottom' }, + largeTitleText: { includeFontPadding: false }, orgRow: { marginTop: 6, flexDirection: 'row', @@ -652,8 +607,8 @@ const styles = StyleSheet.create((theme) => ({ overflow: 'hidden', }, impactBody: { flex: 1, minWidth: 0 }, - numRow: { flexDirection: 'row', alignItems: 'flex-end', marginTop: 4 }, - num: { flexShrink: 1 }, + // prototype `.impact .num`: 76pt, line-height .86, right under the overline + num: { includeFontPadding: false }, numUnit: { fontSize: 36, letterSpacing: -1.08 }, impactLabel: { fontFamily: theme.fontFamily.textBold, @@ -732,6 +687,7 @@ const styles = StyleSheet.create((theme) => ({ gap: CARD_GAP, }, ecard: { + flex: 1, backgroundColor: theme.colors.surface, borderRadius: theme.radius.card, padding: 10, @@ -754,10 +710,12 @@ const styles = StyleSheet.create((theme) => ({ letterSpacing: 0.74, color: theme.colors.navy, }, + // prototype `.dateb b`: 30pt, line-height 1 (AppText trims the Bricolage line) datebDay: { fontFamily: theme.fontFamily.display, fontSize: 30, - lineHeight: 32, + lineHeight: 30, + includeFontPadding: false, letterSpacing: -1.2, color: theme.colors.navy, fontVariant: ['tabular-nums'], @@ -782,10 +740,12 @@ const styles = StyleSheet.create((theme) => ({ borderWidth: 2, borderColor: 'rgba(1,52,79,0.18)', }, + // prototype `.tile .n`: 40pt, line-height .9 (AppText trims the Bricolage line) tileN: { fontFamily: theme.fontFamily.display, fontSize: 40, - lineHeight: 40, + lineHeight: 36, + includeFontPadding: false, letterSpacing: -1.6, fontVariant: ['tabular-nums'], }, @@ -793,6 +753,6 @@ const styles = StyleSheet.create((theme) => ({ fontFamily: theme.fontFamily.textBold, fontSize: 12.5, lineHeight: 15.6, - marginTop: 6, + marginTop: 2, }, }));