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,
},
}));