diff --git a/mobile/src/assets/locales/en/translation.json b/mobile/src/assets/locales/en/translation.json index ded2330e..02061b61 100644 --- a/mobile/src/assets/locales/en/translation.json +++ b/mobile/src/assets/locales/en/translation.json @@ -603,11 +603,14 @@ }, "add": "Add hours", "add_short": "Add", + "sent_toast": "{{n}}h sent for approval", "at": "At {{name}}", "totals": { "approved": "approved", "pending": "pending", - "rejected": "rejected" + "rejected": "rejected", + "approved_singular": "approved", + "rejected_singular": "rejected" }, "errors": { "generic": "Error loading your hours" @@ -645,7 +648,8 @@ "form": { "event": { "label": "Event", - "placeholder": "Choose an event" + "placeholder": "Choose an event", + "none": "No event" }, "task": { "label": "Task", diff --git a/mobile/src/assets/locales/ro/translation.json b/mobile/src/assets/locales/ro/translation.json index 9efc313b..3a93efb6 100644 --- a/mobile/src/assets/locales/ro/translation.json +++ b/mobile/src/assets/locales/ro/translation.json @@ -626,11 +626,14 @@ }, "add": "Adaugă ore", "add_short": "Adaugă", + "sent_toast": "{{n}}h trimise spre aprobare", "at": "La {{name}}", "totals": { "approved": "aprobate", "pending": "în așteptare", - "rejected": "respinse" + "rejected": "respinse", + "approved_singular": "aprobată", + "rejected_singular": "respinsă" }, "errors": { "generic": "Eroare la încărcarea orelor" @@ -668,7 +671,8 @@ "form": { "event": { "label": "Eveniment", - "placeholder": "Alege un eveniment" + "placeholder": "Alege un eveniment", + "none": "Fără eveniment" }, "task": { "label": "Activitate", diff --git a/mobile/src/components/ActivityLogForm.tsx b/mobile/src/components/ActivityLogForm.tsx index b0a62fa5..dc8400fb 100644 --- a/mobile/src/components/ActivityLogForm.tsx +++ b/mobile/src/components/ActivityLogForm.tsx @@ -89,6 +89,7 @@ const ActivityLogForm = ({ label={t('form.event.label')} placeholder={t('form.event.placeholder')} organizationId={userProfile?.activeOrganization?.id as string} + {...(inSheet ? { noneLabel: `${t('form.event.none')}` } : {})} optional disabled={isLoading} /> diff --git a/mobile/src/components/hours/AddHoursSheet.tsx b/mobile/src/components/hours/AddHoursSheet.tsx index 37926ca0..3b47c149 100644 --- a/mobile/src/components/hours/AddHoursSheet.tsx +++ b/mobile/src/components/hours/AddHoursSheet.tsx @@ -33,6 +33,7 @@ import { haptics } from '../../design/haptics'; import { CloseIcon, ICON_STROKE } from '../../design/icons'; import { Button } from '../../design/components/Button'; import { Sheet } from '../../design/components/Sheet'; +import { tight, useEffectiveFontScale } from '../../design/components/Text'; import { SubmitSuccess } from './SubmitSuccess'; /** How long „Trimis spre aprobare” stays before the sheet closes (prototype: 1.7 s). */ @@ -152,6 +153,8 @@ export const AddHoursSheet = forwardRef { activityLog: { ...activityLog, + // „Fără eveniment” clears the event: send none rather than an empty id + eventId: activityLog.eventId || (undefined as unknown as string), volunteerId: organization?.volunteerId, }, }, @@ -214,6 +217,8 @@ export const AddHoursSheet = forwardRef ); const title = `${t('activity_logs:add')}`; + // `.shead h4` Bricolage 30 on a 30pt line, trimmed so Ă is not cut (design/components/Text) + const titleTrim = tight(30, 1, useEffectiveFontScale(fontScaleCap.display)); return ( animatedPosition={animatedPosition} enablePanDownToClose={!guarded && !isLoggingHours} backdropComponent={renderBackdrop} + // `.done` covers the whole sheet, grabber included + {...(sent ? { handleIndicatorStyle: styles.grabberHidden } : {})} accessibilityLabel={title} onDismiss={() => { if (timer.current) { @@ -241,8 +248,8 @@ export const AddHoursSheet = forwardRef {title} @@ -297,6 +304,7 @@ export default AddHoursSheet; const styles = StyleSheet.create((theme) => ({ fill: { flex: 1 }, + grabberHidden: { width: 42, height: 5, opacity: 0 }, scrim: { backgroundColor: theme.colors.scrim }, header: { flexDirection: 'row', @@ -306,11 +314,11 @@ const styles = StyleSheet.create((theme) => ({ paddingBottom: 10, paddingHorizontal: theme.space.gutter, }, + // `.shead h4`: Bricolage 800 30 beside the 46pt close title: { flex: 1, fontFamily: theme.fontFamily.display, - fontSize: 30, - lineHeight: 33, + includeFontPadding: false, letterSpacing: -1.05, color: theme.colors.ink, }, @@ -327,6 +335,7 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.space.gutter, paddingTop: 4, paddingBottom: 24, - gap: 16, + // `.sbody` gap 14 + gap: 14, }, })); diff --git a/mobile/src/components/hours/HoursSummary.tsx b/mobile/src/components/hours/HoursSummary.tsx index a16db959..3b24e602 100644 --- a/mobile/src/components/hours/HoursSummary.tsx +++ b/mobile/src/components/hours/HoursSummary.tsx @@ -1,33 +1,13 @@ -import React, { useId } from 'react'; +import React from 'react'; import { View } from 'react-native'; -import Svg, { Defs, Pattern, Rect } from 'react-native-svg'; +import { useReducedMotion } from 'react-native-reanimated'; import { StyleSheet } from 'react-native-unistyles'; import { useTranslation } from 'react-i18next'; import { fontScaleCap, palette } from '../../design/tokens'; import { AppText } from '../../design/components/Text'; import { ImpactHexagon } from '../../design/components/ImpactHexagon'; - -/** 12×12 legend square with navy / white diagonal stripes (pending hours in the hexagon). */ -const StripedSquare = () => { - const id = `legend-stripes-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`; - return ( - - - - - - - - - - ); -}; +import { StripedSquare } from './StripedSquare'; +import { useCountTo } from './useCountTo'; type LegendKey = 'approved' | 'pending' | 'rejected'; @@ -43,18 +23,30 @@ export interface HoursSummaryProps { rejected: number; /** change it to empty and refill the hexagon (e.g. after sending new hours) */ refillKey?: number; + /** change it to count the approved hours again from 0 (each focus, after sending) */ + replay?: unknown; } /** * The Ore header card (`.hrsHead`): the impact hexagon (approved fill + pending stripes) beside a * legend of the hours per status in Bricolage 30. */ -export const HoursSummary = ({ approved, pending, rejected, refillKey = 0 }: HoursSummaryProps) => { +export const HoursSummary = ({ + approved, + pending, + rejected, + refillKey = 0, + replay, +}: HoursSummaryProps) => { const { t } = useTranslation('activity_logs'); - const items: { key: LegendKey; value: number }[] = [ - { key: 'approved', value: approved || 0 }, - { key: 'pending', value: pending || 0 }, - { key: 'rejected', value: rejected || 0 }, + const reduced = useReducedMotion(); + // prototype `counts()`: approved counts from 0 with the refill, pending from the number shown + const approvedShown = useCountTo(approved || 0, { reduced, replay }); + const pendingShown = useCountTo(pending || 0, { reduced, fromZero: false }); + const items: { key: LegendKey; value: number; shown: number }[] = [ + { key: 'approved', value: approved || 0, shown: approvedShown }, + { key: 'pending', value: pending || 0, shown: pendingShown }, + { key: 'rejected', value: rejected || 0, shown: rejected || 0 }, ]; return ( @@ -69,8 +61,9 @@ export const HoursSummary = ({ approved, pending, rejected, refillKey = 0 }: Hou /> - {items.map(({ key, value }) => { - const label = `${t(`totals.${key}`)}`; + {items.map(({ key, value, shown }) => { + // „1h respinsă”, „42h aprobate” + const label = `${t(value === 1 && key !== 'pending' ? `totals.${key}_singular` : `totals.${key}`)}`; return ( - {`${value}h`} + {`${shown}h`} {SWATCH[key] ? ( ) : ( - + )} {label} @@ -115,13 +108,15 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.card, }, legend: { flex: 1, gap: 8 }, + // `.legend .v`: Bricolage 800 30, line-height 1 (Bricolage needs ~0.9 in RN to sit as tight) value: { fontSize: 30, - lineHeight: 32, + lineHeight: 28, letterSpacing: -0.9, fontVariant: ['tabular-nums'], + includeFontPadding: false, }, key: { flexDirection: 'row', alignItems: 'center', gap: 6 }, square: { width: 12, height: 12, borderRadius: 4 }, - keyText: { fontFamily: theme.fontFamily.textBold, fontSize: 12.5, flexShrink: 1 }, + keyText: { fontFamily: theme.fontFamily.textBold, fontSize: 12.5, lineHeight: 17, flexShrink: 1 }, })); diff --git a/mobile/src/components/hours/LogRow.tsx b/mobile/src/components/hours/LogRow.tsx index f40a2e81..f0bc2b49 100644 --- a/mobile/src/components/hours/LogRow.tsx +++ b/mobile/src/components/hours/LogRow.tsx @@ -1,11 +1,13 @@ -import React, { useEffect } from 'react'; -import { Pressable, View } from 'react-native'; +import React, { useEffect, useRef } from 'react'; +import { Pressable, useWindowDimensions, View } from 'react-native'; import Animated, { interpolate, interpolateColor, useAnimatedStyle, useReducedMotion, useSharedValue, + withDelay, + withSequence, withTiming, } from 'react-native-reanimated'; import { StyleSheet } from 'react-native-unistyles'; @@ -24,8 +26,17 @@ export const LOG_STATUS_TONE: Record = [ActivityLogStatus.REJECTED]: 'no', }; -/** The new-row highlight: a yellow tint that fades to white while a yellow ring pulses. */ +/** + * The new-row highlight (prototype `@keyframes newRow`, 1.4 s standard): the card fades from + * #FFF6CC to white over the whole time while a yellow ring spreads to 4pt (90%) by 60% and + * shrinks away by the end. + */ export const HIGHLIGHT_MS = 1400; +const RING_IN = HIGHLIGHT_MS * 0.6; +const RING_OUT = HIGHLIGHT_MS - RING_IN; +const RING = 4; +// from this text size on, the status chip moves under the text so the title keeps its width +const STACK_FONT_SCALE = 1.2; export interface LogRowProps { log: IActivityLogItem; @@ -33,41 +44,80 @@ export interface LogRowProps { onPress: (id: string) => void; /** the entry was just sent: play the new-row highlight once */ highlight?: boolean; + /** wait this long before the highlight (the sheet is still closing) */ + highlightDelay?: number; } /** * One entry on Ore (`.hrow`): a white 22pt card with the hours in a 52pt paper badge, the task, * the event and the day, and the solid status chip. */ -export const LogRow = ({ log, status, onPress, highlight = false }: LogRowProps) => { +export const LogRow = ({ + log, + status, + onPress, + highlight = false, + highlightDelay = 0, +}: LogRowProps) => { const { t } = useTranslation('activity_log'); const reduced = useReducedMotion(); const title = log.activityType?.name || `${t('other')}`; const meta = [log.event?.name, formatLogShortDay(log.date)].filter(Boolean).join(' · '); const statusLabel = `${t(`status.${status}`)}`; + const { fontScale } = useWindowDimensions(); + const stacked = fontScale >= STACK_FONT_SCALE; // 0 = highlighted (yellow tint), 1 = resting (white card) const progress = useSharedValue(1); + // read when the highlight starts only (the list re-renders with a new value) + const delay = useRef(highlightDelay); + delay.current = highlightDelay; + // 0 = no ring, 1 = 4pt ring at 90% + const ring = useSharedValue(0); const pressed = useSharedValue(0); useEffect(() => { if (!highlight) { return; } + const wait = Math.max(0, delay.current); progress.value = 0; - progress.value = withTiming(1, { - duration: reduced ? duration.reduced * 4 : HIGHLIGHT_MS, - easing: easing.standard, - }); - }, [highlight, progress, reduced]); + progress.value = withDelay( + wait, + withTiming(1, { + duration: reduced ? duration.reduced * 4 : HIGHLIGHT_MS, + easing: easing.standard, + }), + ); + if (!reduced) { + ring.value = 0; + ring.value = withDelay( + wait, + withSequence( + withTiming(1, { duration: RING_IN, easing: easing.standard }), + withTiming(0, { duration: RING_OUT, easing: easing.standard }), + ), + ); + } + }, [highlight, progress, ring, reduced]); const cardStyle = useAnimatedStyle(() => ({ backgroundColor: interpolateColor(progress.value, [0, 1], [palette.highlight, palette.card]), transform: [{ scale: reduced ? 1 : interpolate(pressed.value, [0, 1], [1, 0.985]) }], })); - // the ring grows in until 60% of the highlight, then fades out (none with Reduce Motion) - const ringStyle = useAnimatedStyle(() => ({ - opacity: reduced ? 0 : interpolate(progress.value, [0, 0.6, 1], [0, 0.9, 0]), - })); + // box-shadow 0 0 0 0→4px rgba(255,210,9,0→.9→0): the ring spreads out and back (none with Reduce + // Motion) + const ringStyle = useAnimatedStyle(() => { + const spread = RING * ring.value; + return { + opacity: 0.9 * ring.value, + top: -spread, + left: -spread, + right: -spread, + bottom: -spread, + borderWidth: spread, + borderRadius: 22 + spread, + }; + }); return ( - + {title} - - {meta} - + {!!meta && ( + + {meta} + + )} + {stacked && ( + + )} - + {!stacked && } ); @@ -129,12 +188,6 @@ const styles = StyleSheet.create((theme) => ({ }, ring: { position: 'absolute', - top: -4, - left: -4, - right: -4, - bottom: -4, - borderRadius: theme.radius.row + 4, - borderWidth: 4, borderColor: theme.colors.yellow, }, badge: { @@ -147,10 +200,14 @@ const styles = StyleSheet.create((theme) => ({ }, badgeText: { fontSize: 20, - lineHeight: 24, - letterSpacing: -0.6, + lineHeight: 22, + letterSpacing: -0.5, fontVariant: ['tabular-nums'], + includeFontPadding: false, }, - text: { flex: 1, minWidth: 0, gap: 2 }, - title: { fontSize: 15.5, lineHeight: 20 }, + text: { flex: 1, minWidth: 0 }, + // `.hrow b` 800 15.5 and the 13pt 600 meta, both at line-height 1.35 + title: { fontSize: 15.5, lineHeight: 21 }, + meta: { lineHeight: 17.5 }, + stackedChip: { marginTop: 6 }, })); diff --git a/mobile/src/components/hours/OrganizationChip.tsx b/mobile/src/components/hours/OrganizationChip.tsx index 2d8869c2..1802c5cb 100644 --- a/mobile/src/components/hours/OrganizationChip.tsx +++ b/mobile/src/components/hours/OrganizationChip.tsx @@ -30,7 +30,7 @@ export const OrganizationChip = ({ name, onPress, testID }: OrganizationChipProp @@ -104,18 +104,20 @@ export const OrganizationSwitchSheet = forwardRef ({ + // `.chip.act`: 28pt, padding 0 10 0 8, radius 14, gap 5, Manrope 800 12.5 (a long name wraps) chip: { alignSelf: 'flex-start', flexDirection: 'row', alignItems: 'center', - gap: 6, - minHeight: 32, + gap: 5, + minHeight: 28, maxWidth: '100%', - paddingLeft: 10, - paddingRight: 12, - borderRadius: 16, + paddingVertical: 4, + paddingLeft: 8, + paddingRight: 10, + borderRadius: 14, backgroundColor: theme.colors.navy, }, pressed: { transform: [{ scale: 0.96 }] }, - label: { fontSize: 12.5, flexShrink: 1 }, + label: { fontSize: 12.5, lineHeight: 16, flexShrink: 1 }, })); diff --git a/mobile/src/components/hours/StripedSquare.tsx b/mobile/src/components/hours/StripedSquare.tsx new file mode 100644 index 00000000..c2484e19 --- /dev/null +++ b/mobile/src/components/hours/StripedSquare.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import Svg, { Path, Rect } from 'react-native-svg'; +import { palette } from '../../design/tokens'; + +export interface StripedSquareProps { + size?: number; + /** stripe period across the stripes (CSS `repeating-linear-gradient(135deg, a 0 p/2, b p/2 p)`) */ + period?: number; + stripe?: string; + background?: string; + radius?: number; + testID?: string; +} + +/** + * A rounded square with „/” diagonal stripes, drawn as plain polygons in a clipped View. + * (An SVG `` renders as a few dots on iOS at this size.) + */ +export const StripedSquare = ({ + size = 12, + period = 6, + stripe = palette.navy, + background = palette.white, + radius = 4, + testID, +}: StripedSquareProps) => { + // bands of constant x + y; one period across the stripes is period·√2 along x + y + const step = period * Math.SQRT2; + let d = ''; + for (let a = 0; a < size * 2; a += step) { + const b = a + step / 2; + d += `M${a.toFixed(2)} 0L${b.toFixed(2)} 0L0 ${b.toFixed(2)}L0 ${a.toFixed(2)}Z`; + } + return ( + + + + + + + ); +}; + +export default StripedSquare; + +const styles = StyleSheet.create({ clip: { overflow: 'hidden' } }); diff --git a/mobile/src/components/hours/SubmitSuccess.tsx b/mobile/src/components/hours/SubmitSuccess.tsx index 45c2b64e..7fd071c0 100644 --- a/mobile/src/components/hours/SubmitSuccess.tsx +++ b/mobile/src/components/hours/SubmitSuccess.tsx @@ -1,7 +1,6 @@ import React, { useEffect } from 'react'; import { AccessibilityInfo, View } from 'react-native'; import Animated, { - Extrapolation, interpolate, useAnimatedProps, useAnimatedStyle, @@ -72,6 +71,8 @@ export const SubmitSuccess = ({ title, description, testID }: SubmitSuccessProps const reduced = useReducedMotion(); const shown = useSharedValue(0); const pop = useSharedValue(reduced ? 1 : 0); + // the hexagon's own fade: 160 ms, while the turn and scale ride the bouncy spring + const hexIn = useSharedValue(reduced ? 1 : 0); const check = useSharedValue(reduced ? 0 : CHECK_LENGTH); const burst = useSharedValue(0); @@ -80,10 +81,11 @@ export const SubmitSuccess = ({ title, description, testID }: SubmitSuccessProps if (reduced) { return; } + hexIn.value = withTiming(1, { duration: 160 }); pop.value = withSpring(1, spring.bouncy); check.value = withDelay(360, withTiming(0, { duration: 420, easing: easing.standard })); burst.value = withDelay(120, withTiming(1, { duration: 900, easing: easing.emphasized })); - }, [reduced, shown, pop, check, burst]); + }, [reduced, shown, pop, hexIn, check, burst]); useEffect(() => { AccessibilityInfo.announceForAccessibility(`${title}. ${description}`); @@ -91,7 +93,7 @@ export const SubmitSuccess = ({ title, description, testID }: SubmitSuccessProps const containerStyle = useAnimatedStyle(() => ({ opacity: shown.value })); const hexStyle = useAnimatedStyle(() => ({ - opacity: interpolate(pop.value, [0, 0.16], [0, 1], Extrapolation.CLAMP), + opacity: hexIn.value, transform: [ { scale: interpolate(pop.value, [0, 1], [0.2, 1]) }, { rotate: `${interpolate(pop.value, [0, 1], [-30, 0])}deg` }, @@ -165,16 +167,19 @@ const styles = StyleSheet.create((theme) => ({ left: HEX_W / 2 - 6, top: HEX_H / 2 - 6.5, }, + // `.done h5`: Bricolage 800 32 (two lines at large text sizes stay tight) title: { marginTop: 6, fontSize: 32, - lineHeight: 35, + lineHeight: 31, + includeFontPadding: false, letterSpacing: -1.1, textAlign: 'center', }, description: { fontFamily: theme.fontFamily.text, textAlign: 'center', - maxWidth: 280, + // max-width 28ch of Manrope 15 + maxWidth: 240, }, })); diff --git a/mobile/src/components/hours/useCountTo.ts b/mobile/src/components/hours/useCountTo.ts new file mode 100644 index 00000000..ca6f92e4 --- /dev/null +++ b/mobile/src/components/hours/useCountTo.ts @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from 'react'; + +/** prototype `countTo`: 900 ms, ease-out cubic */ +export const COUNT_MS = 900; + +export interface CountToOptions { + reduced: boolean; + /** changing it counts again from 0 (e.g. each time the screen comes into view) */ + replay?: unknown; + /** the first value counts up from 0 (default) or shows at once */ + fromZero?: boolean; +} + +/** + * A number that counts to `target` (prototype `counts()`): from 0 on mount and on each `replay` + * when `fromZero`, otherwise from the number shown. With Reduce Motion it jumps. + */ +export const useCountTo = ( + target: number, + { reduced, replay, fromZero = true }: CountToOptions, +) => { + const [value, setValue] = useState(reduced || !fromZero ? target : 0); + const current = useRef(value); + const lastReplay = useRef(replay); + useEffect(() => { + if (reduced) { + current.current = target; + setValue(target); + return undefined; + } + let from = current.current; + if (lastReplay.current !== replay) { + lastReplay.current = replay; + if (fromZero) { + from = 0; + } + } + if (from === target) { + current.current = target; + setValue(target); + return undefined; + } + const start = Date.now(); + let raf = 0; + const step = () => { + const x = Math.min(1, (Date.now() - start) / COUNT_MS); + const eased = 1 - Math.pow(1 - x, 3); + const next = Math.round(from + (target - from) * eased); + current.current = next; + setValue(next); + if (x < 1) { + raf = requestAnimationFrame(step); + } + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [target, reduced, replay, fromZero]); + return value; +}; + +export default useCountTo; diff --git a/mobile/src/containers/EventSelect.tsx b/mobile/src/containers/EventSelect.tsx index c265e1a9..8b642a23 100644 --- a/mobile/src/containers/EventSelect.tsx +++ b/mobile/src/containers/EventSelect.tsx @@ -5,9 +5,11 @@ import { getEventsByOrganizationId } from '../services/event/event.api'; interface EventSelectProps extends Omit { organizationId: string; + /** a last option that clears the event (key '') */ + noneLabel?: string; } -const EventSelect = ({ organizationId, ...props }: EventSelectProps) => { +const EventSelect = ({ organizationId, noneLabel, ...props }: EventSelectProps) => { const [structures, setStructures] = useState([]); useEffect(() => { @@ -27,7 +29,14 @@ const EventSelect = ({ organizationId, ...props }: EventSelectProps) => { } }; - return ; + return ( + + ); }; export default EventSelect; diff --git a/mobile/src/design/components/Chip.tsx b/mobile/src/design/components/Chip.tsx index fb1c4ed6..68a3bbd2 100644 --- a/mobile/src/design/components/Chip.tsx +++ b/mobile/src/design/components/Chip.tsx @@ -2,7 +2,7 @@ // StatusChip: solid 28pt pills; every status has a color plus an icon or a texture, so it never // depends on color alone (ok ✓, no ✕, wait = yellow/navy stripes inside a navy outline). // OptionChip: the quick option under a field (`.opt`), navy with yellow text when selected. -import React, { ReactNode, useId } from 'react'; +import React, { ReactNode } from 'react'; import { Pressable, StyleProp, View, ViewStyle } from 'react-native'; import Animated, { interpolate, @@ -10,7 +10,7 @@ import Animated, { useReducedMotion, useSharedValue, } from 'react-native-reanimated'; -import Svg, { Defs, Pattern, Rect } from 'react-native-svg'; +import Svg, { Path, Rect } from 'react-native-svg'; import { StyleSheet } from 'react-native-unistyles'; import { hit, palette, status, StatusKind } from '../tokens'; import { springTo } from '../motion'; @@ -40,25 +40,25 @@ export const statusKind = (apiStatus?: string | null): StatusKind => { } }; -/** 12×12 rounded square with diagonal navy/yellow stripes (the "pending" texture). */ +/** + * 12×12 rounded square with „/” diagonal navy/yellow stripes (the "pending" texture), 2.5pt each. + * Drawn as polygons in a clipped View: an SVG `` renders as + * a few dots on iOS at this size. + */ export const PendingStripes = ({ size = 12 }: { size?: number }) => { - const id = `stripes-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`; + const step = 5 * Math.SQRT2; + let d = ''; + for (let a = 0; a < size * 2; a += step) { + const b = a + step / 2; + d += `M${a.toFixed(2)} 0L${b.toFixed(2)} 0L0 ${b.toFixed(2)}L0 ${a.toFixed(2)}Z`; + } return ( - - - - - - - - - + + + + + + ); }; @@ -171,6 +171,7 @@ export const OptionChip = ({ export default StatusChip; const styles = StyleSheet.create((theme) => ({ + stripes: { borderRadius: 4, overflow: 'hidden' }, status: { minHeight: 28, borderRadius: theme.radius.chip, diff --git a/mobile/src/design/components/Stepper.tsx b/mobile/src/design/components/Stepper.tsx index b15b3ed5..634b21e3 100644 --- a/mobile/src/design/components/Stepper.tsx +++ b/mobile/src/design/components/Stepper.tsx @@ -1,6 +1,7 @@ // Stepper „Îndrăzneț”: a white 72pt bar, − on paper, + on yellow (52×52, radius 18, press .88 on the -// snappy spring) and the value in the middle („4h”) that rolls: the old number slides out and the -// new one slides in, up when it grows and down when it shrinks, clipped in a 44pt window. +// snappy spring) and the value in the middle („4h”) that rolls (prototype `rUp` / `rDn`): the new +// number slides in from below when it grows and from above when it shrinks, fading in, in 360 ms on +// the snappy curve, clipped in a 44pt window. // For VoiceOver / TalkBack the whole bar is one adjustable element (swipe up / down). import React, { useEffect, useRef, useState } from 'react'; import { @@ -12,6 +13,7 @@ import { ViewStyle, } from 'react-native'; import Animated, { + Easing, interpolate, useAnimatedStyle, useReducedMotion, @@ -21,7 +23,7 @@ import Animated, { } from 'react-native-reanimated'; import { StyleSheet } from 'react-native-unistyles'; import { palette } from '../tokens'; -import { springTo } from '../motion'; +import { duration, springTo } from '../motion'; import { haptics } from '../haptics'; import { MinusIcon, PlusIcon } from '../icons'; import { FieldError, FieldLabel, useErrorShake } from './TextField'; @@ -50,6 +52,9 @@ export interface StepperProps { } const WINDOW = 44; +// the prototype's --snappy curve and the 360 ms of `.roll span.in-up / .in-dn` +const ROLL_MS = 360; +const SNAPPY = Easing.bezier(0.3, 1.25, 0.5, 1); const StepButton = ({ kind, @@ -87,7 +92,7 @@ const StepButton = ({ style={disabled ? styles.off : null} > - + ); @@ -115,9 +120,8 @@ export const Stepper = ({ const shake = useErrorShake(error); const current = Number.isFinite(value) ? value : 0; - // the number that rolls out, and which way + // which way the new number rolls in const last = useRef(current); - const [previous, setPrevious] = useState(null); const [direction, setDirection] = useState(1); const roll = useSharedValue(1); @@ -125,25 +129,23 @@ export const Stepper = ({ if (last.current === current) { return; } - setPrevious(last.current); setDirection(current > last.current ? 1 : -1); last.current = current; - roll.value = withSequence(withTiming(0, { duration: 0 }), springTo(1, 'snappy', reduced)); + roll.value = withSequence( + withTiming(0, { duration: 0 }), + reduced + ? withTiming(1, { duration: duration.reduced }) + : withTiming(1, { duration: ROLL_MS, easing: SNAPPY }), + ); }, [current, reduced, roll]); + // from translateY(±100%) and opacity 0; Reduce Motion: a fade only const incoming = useAnimatedStyle( () => ({ - opacity: roll.value, - transform: [{ translateY: (1 - roll.value) * WINDOW * direction }], + opacity: Math.min(1, roll.value), + transform: [{ translateY: reduced ? 0 : (1 - roll.value) * WINDOW * direction }], }), - [direction], - ); - const outgoing = useAnimatedStyle( - () => ({ - opacity: 1 - roll.value, - transform: [{ translateY: -roll.value * WINDOW * direction }], - }), - [direction], + [direction, reduced], ); const clamp = (next: number) => Math.min(max, Math.max(min, next)); @@ -199,17 +201,6 @@ export const Stepper = ({ testID={`${testID}-minus`} /> - {previous !== null && ( - - - {formatValue(previous)} - - - )} {/* 40pt is already large, so this number does not scale further */} @@ -242,6 +233,7 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + // `.stepper` padding 0 10 (8 + the 2pt error border) paddingHorizontal: 8, }, barError: { borderColor: theme.colors.danger, backgroundColor: theme.colors.errorTint }, diff --git a/mobile/src/screens/ActivityLogs.tsx b/mobile/src/screens/ActivityLogs.tsx index 9055d3b9..1175559c 100644 --- a/mobile/src/screens/ActivityLogs.tsx +++ b/mobile/src/screens/ActivityLogs.tsx @@ -1,12 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Pressable, RefreshControl, View } from 'react-native'; -import Animated, { - Extrapolation, - interpolate, - useAnimatedStyle, - useReducedMotion, - useSharedValue, -} from 'react-native-reanimated'; +import Animated, { useReducedMotion, useSharedValue } from 'react-native-reanimated'; import { BottomSheetModal } from '@gorhom/bottom-sheet'; import { StyleSheet } from 'react-native-unistyles'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -43,6 +37,7 @@ import { AddHoursSheet, AddHoursSheetHandle } from '../components/hours/AddHours import { OrganizationChip, OrganizationSwitchSheet } from '../components/hours/OrganizationChip'; import ActivityLogSkeletonItem from '../components/skeleton/activity-log-skeleton-item'; import { EmptyState, LargeTitleHeader, StatusBarScrim } from '../components/ui'; +import { useLargeTitleCollapse } from '../components/ui/LargeTitleHeader'; import { palette } from '../design/tokens'; import { enter } from '../design/motion'; import { ChevronLeftIcon, ICON_STROKE, PlusIcon } from '../design/icons'; @@ -53,8 +48,8 @@ import { Sheet, SheetBackdropScale, useSheetBackdropProgress } from '../design/c // newest first, like the canvas const ORDER = OrderDirection.DESC; -// the large title fades out as the compact bar comes in (LargeTitleHeader: 48pt) -const COLLAPSE_AT = 48; +// after „Trimis spre aprobare” closes: the new row glows and the toast comes this much later +const AFTER_CLOSE_MS = 420; /** * Ore „Îndrăzneț” (prototype `#s-hours`): the active organization as a navy chip (switch on tap), @@ -82,6 +77,12 @@ const ActivityLogs = ({ navigation, route }: any) => { // the entry just sent (highlighted once) and a counter that refills the hexagon const [highlightId, setHighlightId] = useState(null); const [refillKey, setRefillKey] = useState(0); + // each focus counts the approved hours again from 0, with the hexagon refill + const [focusKey, setFocusKey] = useState(0); + const sentAt = useRef(0); + const toastTimer = useRef | null>(null); + // the first list enters as one block (stagger step 4); after a segment change rows stagger from 0 + const [swapped, setSwapped] = useState(false); const { approvedHours, rejectedHours, pendingHours } = useActivityLogs(); const { userProfile } = useUserProfile(); const organization = userProfile?.activeOrganization; @@ -124,9 +125,19 @@ const ActivityLogs = ({ navigation, route }: any) => { refetchInactive: false, }); reload(); + setFocusKey((key) => key + 1); }, [queryClient, reload]), ); + useEffect( + () => () => { + if (toastTimer.current) { + clearTimeout(toastTimer.current); + } + }, + [], + ); + const items = useMemo( () => (data?.pages ?? []).flatMap((page) => page.items) as IActivityLogItem[], [data], @@ -162,14 +173,27 @@ const ActivityLogs = ({ navigation, route }: any) => { // after „Trimis spre aprobare”: back to the pending entries, the new one glows, the hexagon refills const onSent = useCallback( (log: IActivityLog) => { + sentAt.current = Date.now(); setStatus(ActivityLogStatus.PENDING); setHighlightId(log?.id ?? null); setRefillKey((key) => key + 1); reload(); + if (toastTimer.current) { + clearTimeout(toastTimer.current); + } + toastTimer.current = setTimeout(() => { + toastTimer.current = null; + Toast.show({ type: 'success', text1: `${t('sent_toast', { n: log?.hours ?? '' })}` }); + }, AFTER_CLOSE_MS); }, - [reload], + [reload, t], ); + const onStatus = useCallback((next: ActivityLogStatus) => { + setSwapped(true); + setStatus(next); + }, []); + const accessory = useMemo( () => asTab && canAdd @@ -195,12 +219,10 @@ const ActivityLogs = ({ navigation, route }: any) => { } }; - const titleStyle = useAnimatedStyle(() => ({ - opacity: interpolate(scrollY.value, [0, COLLAPSE_AT], [1, 0], Extrapolation.CLAMP), - })); + const titleStyle = useLargeTitleCollapse(scrollY); const header = ( - + {!!organization && ( { )} {/* Opened from the volunteer page: the back button and „+ Adaugă” join the title row (no tab bar there to hold the action). */} - + {!asTab && ( { )} {!!organization && ( - + )} {!!organization && ( - + { { key: ActivityLogStatus.REJECTED, label: `${t('tabs.rejected')}` }, ]} value={status} - onChange={setStatus} + onChange={onStatus} testID="hours-status" /> @@ -319,12 +346,13 @@ const ActivityLogs = ({ navigation, route }: any) => { keyExtractor={(item) => item.id} ListHeaderComponent={header} renderItem={({ item, index }) => ( - + )} @@ -350,7 +378,7 @@ const ActivityLogs = ({ navigation, route }: any) => { testID="hours-list" /> - + @@ -395,8 +423,12 @@ const styles = StyleSheet.create((theme) => ({ root: { flex: 1, backgroundColor: theme.colors.black }, screen: { backgroundColor: theme.colors.paper }, list: { paddingHorizontal: theme.space.gutter, flexGrow: 1 }, - header: { gap: 14, paddingBottom: 14 }, + // prototype `#s-hours`: chip, 10, title, 16, card, 14, segmented, 14, entries + header: { paddingBottom: 14 }, titleRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + titleAfterChip: { marginTop: 10 }, + summary: { marginTop: 16 }, + segment: { marginTop: 14 }, back: { width: 44, height: 44, @@ -406,7 +438,7 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: 'center', }, backPressed: { transform: [{ scale: 0.94 }] }, - title: { flex: 1 }, + title: { flex: 1, transformOrigin: 'left bottom' }, item: { marginBottom: 10 }, more: { paddingVertical: 20 }, })); diff --git a/mobile/src/screens/hours.test.tsx b/mobile/src/screens/hours.test.tsx index bfe749bf..38e593d8 100644 --- a/mobile/src/screens/hours.test.tsx +++ b/mobile/src/screens/hours.test.tsx @@ -6,6 +6,7 @@ import { BottomTabBarProps, createBottomTabNavigator } from '@react-navigation/b import { SafeAreaProvider } from 'react-native-safe-area-context'; import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react-native'; import * as Haptics from 'expo-haptics'; +import Toast from 'react-native-toast-message'; import i18n from '../common/config/i18n'; import ActivityLogs from './ActivityLogs'; import ActivityLog from './ActivityLog'; @@ -273,7 +274,10 @@ describe('Ore', () => { useStore.setState({ counters: { approved: 151, pending: 3, rejected: 5 } }); }); - expect(within(screen.getByTestId('hours-total-approved')).getByText('151h')).toBeTruthy(); + // the legend counts to the new totals (prototype count-up) + await waitFor(() => + expect(within(screen.getByTestId('hours-total-approved')).getByText('151h')).toBeTruthy(), + ); expect(mockRefetchList).toHaveBeenCalledTimes(1); expect(mockRefetchCounters).toHaveBeenCalledTimes(1); }); @@ -305,7 +309,10 @@ describe('Ore', () => { expect( within(screen.getByTestId('hours-organization')).getByText('Asociația Exemplu'), ).toBeTruthy(); - expect(within(screen.getByTestId('hours-total-approved')).getByText('145h')).toBeTruthy(); + // approved counts up from 0 with the hexagon refill + await waitFor(() => + expect(within(screen.getByTestId('hours-total-approved')).getByText('145h')).toBeTruthy(), + ); expect(within(screen.getByTestId('hours-total-pending')).getByText('9h')).toBeTruthy(); expect(within(screen.getByTestId('hours-total-rejected')).getByText('5h')).toBeTruthy(); const last = mockListCalls[mockListCalls.length - 1]; @@ -500,6 +507,15 @@ describe('Ore', () => { await waitFor(() => expect(mockListCalls[mockListCalls.length - 1][3]).toBe(ActivityLogStatus.PENDING), ); + // then the toast confirms it (prototype: „4h trimise spre aprobare”) + await waitFor(() => + expect(Toast.show).toHaveBeenCalledWith({ + type: 'success', + text1: '3h trimise spre aprobare', + }), + ); + // no event chosen: no empty event id is sent + expect(mockCreate.mock.calls[0][0].activityLog.eventId).toBeUndefined(); }); });