diff --git a/mobile/src/components/events/EventCard.tsx b/mobile/src/components/events/EventCard.tsx index 57759c23..7973c68b 100644 --- a/mobile/src/components/events/EventCard.tsx +++ b/mobile/src/components/events/EventCard.tsx @@ -135,40 +135,24 @@ export const EventCard = ({ > - - {event.name} - - + {/* nothing is cut: long names and dates wrap (Dynamic Type up to 1.35×) */} + {event.name} + - + {when} {!!event.location && ( - + {event.location} )} {!!event.organizationName && ( - + {event.organizationName} )} @@ -207,33 +191,38 @@ const styles = StyleSheet.create((theme) => ({ // inside the legacy Group, which pads the sides row: { paddingVertical: 12 }, divider: { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: theme.colors.line }, + // no fixed height: it stretches with the card and grows with its own text (capped at 1.15×) dateBlock: { width: 60, - minHeight: 84, borderRadius: 18, alignItems: 'center', justifyContent: 'center', paddingVertical: 8, }, - dateBlockHero: { width: 76, minHeight: 100, borderRadius: 20, paddingVertical: 10 }, + dateBlockHero: { width: 76, paddingVertical: 10 }, dateSmall: { fontFamily: theme.fontFamily.textHeavy, fontSize: 10.5, - lineHeight: 13, - letterSpacing: 0.7, + lineHeight: 14, + letterSpacing: 0.74, + includeFontPadding: false, }, + // handbook: Bricolage 30, line-height 1 (digits: no accents to clip) dateDay: { fontFamily: theme.fontFamily.display, fontSize: 30, - lineHeight: 32, + lineHeight: 30, letterSpacing: -1.2, fontVariant: ['tabular-nums'], + includeFontPadding: false, }, - dateDayHero: { fontSize: 40, lineHeight: 42, letterSpacing: -1.6 }, - body: { flex: 1, gap: 3, paddingVertical: 2 }, + dateDayHero: { fontSize: 40, lineHeight: 40, letterSpacing: -1.6 }, + // .et / .em: the first meta line sits 3pt under the title, the others follow without a gap + body: { flex: 1, minWidth: 0 }, meta: { flexDirection: 'row', alignItems: 'center', gap: 5 }, - metaText: { flexShrink: 1 }, - chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 6 }, + firstMeta: { marginTop: 3 }, + metaText: { flex: 1, minWidth: 0 }, + chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }, // the plain chip on a white card: a hairline so it still reads as a chip plainChip: { borderWidth: 1, borderColor: theme.colors.line }, })); diff --git a/mobile/src/components/events/EventFiltersSheet.tsx b/mobile/src/components/events/EventFiltersSheet.tsx index e77a5086..af7b8a8d 100644 --- a/mobile/src/components/events/EventFiltersSheet.tsx +++ b/mobile/src/components/events/EventFiltersSheet.tsx @@ -118,8 +118,16 @@ export default EventFiltersSheet; const styles = StyleSheet.create((theme) => ({ section: { gap: 10 }, options: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, - footer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 16 }, + // at large text sizes the button moves under "Șterge filtrele" instead of squeezing its label + footer: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + justifyContent: 'space-between', + columnGap: 16, + rowGap: 12, + }, clear: { textDecorationLine: 'underline', color: theme.colors.ink }, dim: { opacity: 0.45 }, - apply: { flexShrink: 1 }, + apply: { marginLeft: 'auto', maxWidth: '100%' }, })); diff --git a/mobile/src/components/events/EventSearchModal.tsx b/mobile/src/components/events/EventSearchModal.tsx index ef3fc3f2..f311d903 100644 --- a/mobile/src/components/events/EventSearchModal.tsx +++ b/mobile/src/components/events/EventSearchModal.tsx @@ -99,7 +99,7 @@ const EventSearch = ({ {label} - + {value} @@ -283,7 +283,7 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: 20, gap: 16, }, - sheadValue: { flexShrink: 1 }, + sheadValue: { flexShrink: 1, textAlign: 'right' }, sectionTitle: { paddingTop: 20, paddingHorizontal: 20, paddingBottom: 2 }, sectionTitleText: { fontSize: 28, lineHeight: 31 }, sectionBody: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 16 }, @@ -320,10 +320,12 @@ const styles = StyleSheet.create((theme) => ({ paddingTop: 12, paddingHorizontal: theme.space.gutter, flexDirection: 'row', + flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', - gap: 16, + columnGap: 16, + rowGap: 12, }, clear: { textDecorationLine: 'underline' }, - apply: { flexShrink: 1 }, + apply: { marginLeft: 'auto', maxWidth: '100%' }, })); diff --git a/mobile/src/design/components/SegmentedControl.tsx b/mobile/src/design/components/SegmentedControl.tsx index 71ef0a4d..bbcc48e4 100644 --- a/mobile/src/design/components/SegmentedControl.tsx +++ b/mobile/src/design/components/SegmentedControl.tsx @@ -107,11 +107,13 @@ export function SegmentedControl({ } onChange(option.key); }} - style={styles.segment} + // Dynamic Type: a label of several words wraps to a second line instead of being cut; + // one-word labels keep their width, so they never break mid-word + style={[styles.segment, option.label.includes(' ') ? null : styles.segmentOneWord]} > {option.icon?.(selected)} @@ -121,7 +123,7 @@ export function SegmentedControl({ {option.count} @@ -156,17 +158,22 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 1, flexBasis: 'auto', minHeight: 42, + minWidth: 0, borderRadius: 17, paddingHorizontal: 8, + paddingVertical: 3, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, }, + segmentOneWord: { flexShrink: 0 }, label: { + flexShrink: 1, fontFamily: theme.fontFamily.textHeavy, fontSize: 13.5, - lineHeight: 18, + lineHeight: 17, + textAlign: 'center', }, count: { minWidth: 20, diff --git a/mobile/src/screens/Event.tsx b/mobile/src/screens/Event.tsx index bf3b8200..1cc05136 100644 --- a/mobile/src/screens/Event.tsx +++ b/mobile/src/screens/Event.tsx @@ -1,7 +1,7 @@ import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; -import { Platform, Pressable, ScrollView, Text, View } from 'react-native'; +import { Platform, Pressable, ScrollView, Text, useWindowDimensions, View } from 'react-native'; import * as Linking from 'expo-linking'; -import Animated, { useReducedMotion } from 'react-native-reanimated'; +import Animated, { FadeIn, FadeInDown, useReducedMotion } from 'react-native-reanimated'; import { BottomSheetModal } from '@gorhom/bottom-sheet'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { StyleSheet } from 'react-native-unistyles'; @@ -38,6 +38,7 @@ import { CalendarIcon, CheckIcon, ChevronLeftIcon, + ChevronRightIcon, GlobeIcon, ICON_STROKE, LockIcon, @@ -46,9 +47,22 @@ import { UsersIcon, } from '../design/icons'; import { elevation, fontScaleCap, palette, tabBar } from '../design/tokens'; -import { enter } from '../design/motion'; +import { duration, enter, spring } from '../design/motion'; import { haptics } from '../design/haptics'; +/** + * The hero enters like the handbook's .dhero: date block, then the title 60 ms later, then the + * chips at 110 ms (gentle spring, 16pt lift); Reduce Motion fades. + */ +const heroEnter = (delay: number, reduced: boolean) => + reduced + ? FadeIn.duration(duration.reduced) + : FadeInDown.withInitialValues({ opacity: 0, transform: [{ translateY: 16 }] }) + .springify() + .stiffness(spring.gentle.stiffness) + .damping(spring.gentle.damping) + .delay(delay); + /** A fact on a white card: a yellow 44pt icon tile, a bold line and a caption. */ const InfoRow = ({ icon: Icon, @@ -56,6 +70,8 @@ const InfoRow = ({ title, caption, captionUnderline, + captionFirst, + chevron, onPress, accessibilityLabel, testID, @@ -67,6 +83,10 @@ const InfoRow = ({ caption?: string; /** the caption is the row's action ("Deschide în Hărți") */ captionUnderline?: boolean; + /** the caption reads first ("Organizat de" above the organization) */ + captionFirst?: boolean; + /** a chevron at the end: the row opens a page */ + chevron?: boolean; onPress?: () => void; accessibilityLabel?: string; testID?: string; @@ -79,7 +99,7 @@ const InfoRow = ({ ) : null)} - + {title} @@ -93,6 +113,7 @@ const InfoRow = ({ )} + {chevron && } ); if (onPress) { @@ -138,6 +159,9 @@ const Event = ({ navigation, route }: any) => { }; const insets = useSafeAreaInsets(); const reduced = useReducedMotion(); + // at large text sizes the action bar stacks: status above, buttons below (nothing is cut) + const { fontScale } = useWindowDimensions(); + const stackedBar = fontScale >= 1.25; // light text over the navy hero useFocusedStatusBarStyle('light'); const { userProfile } = useUserProfile(); @@ -258,7 +282,9 @@ const Event = ({ navigation, route }: any) => { // the bar floats where the tab bar sits on the tab screens const barBottom = insets.bottom > 0 ? tabBar.bottom : 16; - const barSpace = event && !finished ? barBottom + 64 + 24 : insets.bottom + 24; + // the bar's own height (it grows with Dynamic Type and stacks at large sizes) + const [barHeight, setBarHeight] = useState(64); + const barSpace = event && !finished ? barBottom + barHeight + 24 : insets.bottom + 24; if (!event && !isLoadingEvent && getEventError) { return eventGone ? ( @@ -307,15 +333,15 @@ const Event = ({ navigation, route }: any) => { style={({ pressed }) => [styles.back, pressed ? styles.backPressed : null]} testID="event-back" > - + {event && ( <> - + - + { - + {event.isPublic ? ( { {joined && registration === EventRegistrationStatus.REJECTED && !!event.rejectionReason && ( - + {t('registration.reason', { reason: event.rejectionReason })} @@ -376,6 +402,16 @@ const Event = ({ navigation, route }: any) => { leading={} title={event.organizationName || ''} caption={`${t('organized_by_caption')}`} + captionFirst + chevron={!!event.organizationId} + onPress={ + event.organizationId + ? () => + navigation.navigate('organization-profile', { + organizationId: event.organizationId, + }) + : undefined + } accessibilityLabel={`${t('organized_by', { name: event.organizationName })}`} testID="event-organizer" /> @@ -438,15 +474,16 @@ const Event = ({ navigation, route }: any) => { {event && !finished && ( setBarHeight(Math.round(e.nativeEvent.layout.height))} testID="event-bar" > - - + + {barStatus} - + {formatEventWhen(event.startDate, event.endDate)} @@ -550,21 +587,23 @@ const styles = StyleSheet.create((theme) => ({ }, backPressed: { backgroundColor: 'rgba(255,255,255,0.24)' }, heroRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 14, marginTop: 18 }, - heroTitle: { flex: 1 }, + heroTitle: { flex: 1, minWidth: 0 }, + // handbook: Bricolage 38, line-height .96, -0.035em title: { fontFamily: theme.fontFamily.display, fontSize: 38, lineHeight: 37, letterSpacing: -1.33, color: theme.colors.white, + includeFontPadding: false, }, heroChips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 14 }, reason: { marginTop: 8 }, skeleton: { paddingHorizontal: theme.space.gutter, paddingTop: 16 }, cards: { paddingHorizontal: theme.space.gutter, paddingTop: 14, gap: 10 }, card: { backgroundColor: theme.colors.card, borderRadius: theme.radius.card, overflow: 'hidden' }, - // starts under the text, past the 44pt tile - line: { height: StyleSheet.hairlineWidth, backgroundColor: theme.colors.line, marginLeft: 70 }, + // handbook: a full-width 1px line between the facts of a card + line: { height: 1, backgroundColor: theme.colors.line }, info: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 14, minHeight: 72 }, infoPressed: { backgroundColor: theme.colors.paper }, iconTile: { @@ -575,7 +614,8 @@ const styles = StyleSheet.create((theme) => ({ alignItems: 'center', justifyContent: 'center', }, - infoText: { flex: 1, gap: 1 }, + infoText: { flex: 1, minWidth: 0, gap: 1 }, + infoTextReversed: { flexDirection: 'column-reverse' }, infoTitle: { fontFamily: theme.fontFamily.textHeavy, fontSize: 15.5, lineHeight: 21 }, infoCaption: { fontFamily: theme.fontFamily.text }, underline: { textDecorationLine: 'underline', fontFamily: theme.fontFamily.textBold }, @@ -594,9 +634,19 @@ const styles = StyleSheet.create((theme) => ({ gap: 12, ...elevation.float, }, - barText: { flex: 1 }, + barStacked: { flexDirection: 'column', alignItems: 'stretch', paddingRight: 18, paddingTop: 14 }, + barText: { flex: 1, minWidth: 0 }, + barTextStacked: { flex: 0 }, barStatus: { fontSize: 15 }, - barActions: { flexDirection: 'row', alignItems: 'center', gap: 14 }, + // handbook .dbar .ss: 12.5 / 600 + barSub: { fontSize: 12.5, lineHeight: 17 }, + barActions: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 14, + }, confirmIcon: { width: 64, height: 64, diff --git a/mobile/src/screens/Events.tsx b/mobile/src/screens/Events.tsx index 7d699450..2493638b 100644 --- a/mobile/src/screens/Events.tsx +++ b/mobile/src/screens/Events.tsx @@ -139,6 +139,21 @@ const Events = ({ navigation, route }: any) => { const scrollY = useSharedValue(0); const onScroll = useTabBarScroll(scrollY); + // entering the tab again replays the entrance (title, search, segments, cards: 40 ms apart); + // coming back from an event does not (that is a pop, not a tab switch) + const [entranceKey, setEntranceKey] = useState(0); + const replayEntrance = useRef(false); + useEffect( + () => + navigation.addListener?.('tabPress', () => { + if (!navigation.isFocused?.()) { + replayEntrance.current = true; + setEntranceKey((key) => key + 1); + } + }), + [navigation], + ); + const queryFilters = useMemo(() => toEventQueryFilters(filters), [filters]); const { data: events, @@ -176,8 +191,12 @@ const Events = ({ navigation, route }: any) => { }, [filters, organizations, t]); // a new tab or new filters remount the cards, so they stagger in again - const listKey = `${eventFilter}:${JSON.stringify(filters)}`; + const listKey = `${entranceKey}:${eventFilter}:${JSON.stringify(filters)}`; const initialListKey = useRef(listKey); + if (replayEntrance.current) { + replayEntrance.current = false; + initialListKey.current = listKey; + } if (initialListKey.current && listKey !== initialListKey.current) { initialListKey.current = null; } @@ -254,7 +273,7 @@ const Events = ({ navigation, route }: any) => { : `${t('search.filters_count', { n: activeFilters })}`; const header = ( - + @@ -267,13 +286,13 @@ const Events = ({ navigation, route }: any) => { > - + {t('search.title')} {!!summary && ( @@ -414,10 +433,11 @@ const styles = StyleSheet.create((theme) => ({ alignItems: 'center', justifyContent: 'center', }, + // handbook .badge-n: 20pt at top 2 / right 0, with a 2pt paper ring around it filterCount: { position: 'absolute', - top: -6, - right: -6, + top: 0, + right: -2, minWidth: 24, height: 24, borderRadius: 12,