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,