mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 16:07:12 +00:00
feat: restyle calendar events and toasts to the app design
This commit is contained in:
+227
-66
@@ -1,12 +1,16 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, Animated, Easing, PanResponder, useWindowDimensions,
|
||||
type AccessibilityActionEvent, type LayoutChangeEvent,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useAnimDuration } from '../theme/dynamic';
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info } from 'lucide-react-native';
|
||||
import { spacing, radius, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||
import { useShouldAnimate } from '../theme/dynamic';
|
||||
import { useToastStore, type Toast } from '../stores/toast-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { shouldClaimToastSwipe, toastSwipeRelease } from './toast-swipe';
|
||||
|
||||
/**
|
||||
* Renders the toast queue above the tab bar. App.tsx gives every stack screen
|
||||
@@ -25,70 +29,196 @@ export function ToastHost(): React.ReactElement | null {
|
||||
);
|
||||
}
|
||||
|
||||
const ENTER_MS = 200;
|
||||
const EXIT_MS = 160;
|
||||
|
||||
// Icons and text in a status colour (repos/branding/APP.md): the palette's
|
||||
// error and warning fills are too light for an icon on a light ground.
|
||||
const STATUS_ICON = {
|
||||
light: { error: '#dc2626', warning: '#d97706' },
|
||||
dark: { error: '#f87171', warning: '#fbbf24' },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The toast card from repos/branding/APP.md: a plain floating card whose icon
|
||||
* and countdown line carry the status. No close button on the phone; a
|
||||
* sideways swipe dismisses it.
|
||||
*/
|
||||
function ToastCard({ toast }: { toast: Toast }) {
|
||||
const c = useColors();
|
||||
const cardStyles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const scheme = useResolvedTheme();
|
||||
const cardStyles = React.useMemo(() => makeStyles(c, scheme), [c, scheme]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const removeToast = useToastStore((s) => s.removeToast);
|
||||
const duration = useAnimDuration(200);
|
||||
const animate = useShouldAnimate();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
|
||||
const opacity = React.useRef(new Animated.Value(0)).current;
|
||||
const translateY = React.useRef(new Animated.Value(24)).current;
|
||||
const translateX = React.useRef(new Animated.Value(0)).current;
|
||||
// Share of the countdown line still filled, 1 → 0.
|
||||
const remainingShare = React.useRef(new Animated.Value(1)).current;
|
||||
const cardWidth = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
Animated.parallel([
|
||||
const duration = animate ? ENTER_MS : 0;
|
||||
const enter = Animated.parallel([
|
||||
Animated.timing(opacity, { toValue: 1, duration, useNativeDriver: true }),
|
||||
Animated.timing(translateY, { toValue: 0, duration, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
]).start();
|
||||
]);
|
||||
enter.start();
|
||||
return () => enter.stop();
|
||||
}, [animate, opacity, translateY]);
|
||||
|
||||
// Leave after the toast's duration, counted from when it was queued (a
|
||||
// screen change remounts the card). The countdown line runs out with it;
|
||||
// with animations off it stays full instead of moving.
|
||||
React.useEffect(() => {
|
||||
const remaining = Math.max(0, toast.duration - (Date.now() - toast.createdAt));
|
||||
const timer = setTimeout(() => removeToast(toast.id), remaining);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast, opacity, translateY, duration, removeToast]);
|
||||
if (!animate) {
|
||||
remainingShare.setValue(1);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
remainingShare.setValue(toast.duration > 0 ? remaining / toast.duration : 0);
|
||||
const countdown = Animated.timing(remainingShare, {
|
||||
toValue: 0,
|
||||
duration: remaining,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true,
|
||||
});
|
||||
countdown.start();
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
countdown.stop();
|
||||
};
|
||||
}, [toast, animate, remainingShare, removeToast]);
|
||||
|
||||
const accent = toast.type === 'success'
|
||||
? c.success
|
||||
: toast.type === 'error'
|
||||
? c.error
|
||||
: toast.type === 'warning'
|
||||
? c.warning
|
||||
: c.info;
|
||||
// The PanResponder is built once; its handlers read these through a ref.
|
||||
const latest = React.useRef({ animate, screenWidth, id: toast.id, removeToast });
|
||||
latest.current = { animate, screenWidth, id: toast.id, removeToast };
|
||||
|
||||
const responder = React.useMemo(
|
||||
() => {
|
||||
const springBack = () => {
|
||||
if (!latest.current.animate) {
|
||||
translateX.setValue(0);
|
||||
return;
|
||||
}
|
||||
Animated.spring(translateX, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
};
|
||||
return PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => false,
|
||||
onMoveShouldSetPanResponder: (_, g) => shouldClaimToastSwipe(g),
|
||||
onPanResponderGrant: () => translateX.stopAnimation(),
|
||||
onPanResponderMove: (_, g) => translateX.setValue(g.dx),
|
||||
onPanResponderRelease: (_, g) => {
|
||||
const direction = toastSwipeRelease(g, cardWidth.current);
|
||||
if (direction === 0) {
|
||||
springBack();
|
||||
return;
|
||||
}
|
||||
const { animate: animated, screenWidth: width, id, removeToast: remove } = latest.current;
|
||||
Animated.timing(translateX, {
|
||||
toValue: direction * width,
|
||||
duration: animated ? EXIT_MS : 0,
|
||||
useNativeDriver: true,
|
||||
}).start(() => remove(id));
|
||||
},
|
||||
onPanResponderTerminate: springBack,
|
||||
onPanResponderTerminationRequest: () => false,
|
||||
});
|
||||
},
|
||||
[translateX],
|
||||
);
|
||||
|
||||
// Fades as it is dragged away.
|
||||
const cardOpacity = React.useMemo(
|
||||
() => Animated.multiply(
|
||||
opacity,
|
||||
translateX.interpolate({
|
||||
inputRange: [-screenWidth, 0, screenWidth],
|
||||
outputRange: [0, 1, 0],
|
||||
extrapolate: 'clamp',
|
||||
}),
|
||||
),
|
||||
[opacity, translateX, screenWidth],
|
||||
);
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => {
|
||||
cardWidth.current = e.nativeEvent.layout.width;
|
||||
};
|
||||
|
||||
const statusIcon = STATUS_ICON[scheme];
|
||||
const iconColor = toast.type === 'error'
|
||||
? statusIcon.error
|
||||
: toast.type === 'warning'
|
||||
? statusIcon.warning
|
||||
: c.mutedForeground;
|
||||
const countdownColor = toast.type === 'error'
|
||||
? c.error
|
||||
: toast.type === 'warning'
|
||||
? c.warning
|
||||
: c.mutedForeground;
|
||||
const Icon = toast.type === 'success'
|
||||
? CheckCircle2
|
||||
: toast.type === 'error'
|
||||
? XCircle
|
||||
? AlertCircle
|
||||
: toast.type === 'warning'
|
||||
? AlertTriangle
|
||||
: Info;
|
||||
|
||||
// Without a close button, screen readers dismiss through an action.
|
||||
const dismissActions = React.useMemo(
|
||||
() => [{ name: 'dismiss', label: t('common.dismiss', 'Dismiss') }, { name: 'escape' }],
|
||||
[t],
|
||||
);
|
||||
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
|
||||
if (e.nativeEvent.actionName === 'dismiss' || e.nativeEvent.actionName === 'escape') {
|
||||
removeToast(toast.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[cardStyles.card, { borderLeftColor: accent, opacity, transform: [{ translateY }] }]}
|
||||
accessibilityRole="alert"
|
||||
accessibilityLiveRegion="polite"
|
||||
style={[cardStyles.shadow, { opacity: cardOpacity, transform: [{ translateY }, { translateX }] }]}
|
||||
onLayout={onLayout}
|
||||
{...responder.panHandlers}
|
||||
>
|
||||
<Icon size={18} color={accent} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={cardStyles.title} numberOfLines={2}>{toast.title}</Text>
|
||||
{toast.message ? <Text style={cardStyles.message} numberOfLines={3}>{toast.message}</Text> : null}
|
||||
</View>
|
||||
{toast.action ? (
|
||||
<Pressable
|
||||
onPress={() => { toast.action?.onPress(); removeToast(toast.id); }}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
style={({ pressed }) => [cardStyles.actionBtn, pressed && cardStyles.actionBtnPressed]}
|
||||
<View style={cardStyles.card} accessibilityRole="alert" accessibilityLiveRegion="polite">
|
||||
<Icon size={18} color={iconColor} style={cardStyles.icon} />
|
||||
<View
|
||||
style={cardStyles.body}
|
||||
accessible
|
||||
accessibilityActions={dismissActions}
|
||||
onAccessibilityAction={onAccessibilityAction}
|
||||
>
|
||||
<Text style={[cardStyles.actionText, { color: accent }]}>{toast.action.label}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
onPress={() => removeToast(toast.id)}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.dismiss', 'Dismiss')}
|
||||
style={cardStyles.close}
|
||||
>
|
||||
<X size={14} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
<Text style={cardStyles.title} numberOfLines={2}>{toast.title}</Text>
|
||||
{toast.message ? <Text style={cardStyles.message} numberOfLines={3}>{toast.message}</Text> : null}
|
||||
</View>
|
||||
{toast.action ? (
|
||||
<Pressable
|
||||
onPress={() => { toast.action?.onPress(); removeToast(toast.id); }}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
style={({ pressed }) => [cardStyles.action, pressed && cardStyles.actionPressed]}
|
||||
>
|
||||
{/* The underline is a border so it can sit 3px under the
|
||||
baseline; textDecorationLine has no offset in RN. */}
|
||||
<View style={cardStyles.actionUnderline}>
|
||||
<Text style={cardStyles.actionText} numberOfLines={1}>{toast.action.label}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<View pointerEvents="none" style={cardStyles.countdownTrack} />
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
cardStyles.countdownFill,
|
||||
{ backgroundColor: countdownColor, transform: [{ scaleX: remainingShare }] },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -103,31 +233,62 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
const COUNTDOWN_HEIGHT = 2;
|
||||
|
||||
function makeStyles(c: ThemePalette, scheme: 'light' | 'dark') {
|
||||
return StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
paddingLeft: spacing.md,
|
||||
paddingRight: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
borderLeftWidth: 3,
|
||||
// Carries the shadow; the card inside clips the countdown line to the
|
||||
// rounded corners, which would clip an iOS shadow on the same view.
|
||||
shadow: {
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.popover,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: scheme === 'dark' ? 0.4 : 0.1,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 6,
|
||||
},
|
||||
title: { ...typography.bodyMedium, color: c.text },
|
||||
message: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
actionBtn: { paddingHorizontal: spacing.sm, paddingVertical: 6, borderRadius: radius.sm },
|
||||
actionBtnPressed: { backgroundColor: c.muted },
|
||||
actionText: { ...typography.captionMedium },
|
||||
close: { padding: 4 },
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.sm,
|
||||
paddingTop: 12,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
// Room for the countdown line.
|
||||
paddingBottom: 14,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.popover,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// Centred on the title's first line.
|
||||
icon: { marginTop: 1 },
|
||||
body: { flex: 1 },
|
||||
title: { fontSize: 14.5, lineHeight: 20, fontWeight: '500', color: c.text },
|
||||
message: { fontSize: 13, lineHeight: 18, color: c.mutedForeground, marginTop: 2 },
|
||||
action: { alignSelf: 'center' },
|
||||
actionPressed: { opacity: 0.6 },
|
||||
actionUnderline: { borderBottomWidth: 1, borderBottomColor: c.text },
|
||||
actionText: { fontSize: 13, lineHeight: 16, fontWeight: '600', color: c.text },
|
||||
countdownTrack: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: COUNTDOWN_HEIGHT,
|
||||
backgroundColor: c.text,
|
||||
opacity: 0.06,
|
||||
},
|
||||
countdownFill: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: COUNTDOWN_HEIGHT,
|
||||
opacity: 0.6,
|
||||
transformOrigin: 'left',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const SRC = join(__dirname, '..', '..');
|
||||
const TRANSLATED_FILES: Record<string, string[]> = {
|
||||
'screens/CalendarScreen.tsx': [],
|
||||
'components/calendar/AgendaView.tsx': [],
|
||||
'components/calendar/EventBlock.tsx': [],
|
||||
'components/calendar/EventCard.tsx': [],
|
||||
'components/calendar/MonthScrollView.tsx': [],
|
||||
'components/calendar/MonthView.tsx': [],
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shouldClaimToastSwipe, toastSwipeRelease } from '../toast-swipe';
|
||||
|
||||
const g = (dx: number, dy = 0, vx = 0) => ({ dx, dy, vx });
|
||||
|
||||
describe('shouldClaimToastSwipe', () => {
|
||||
it('takes sideways drags in either direction', () => {
|
||||
expect(shouldClaimToastSwipe(g(10))).toBe(true);
|
||||
expect(shouldClaimToastSwipe(g(-10, 4))).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves taps, jitter and vertical moves alone', () => {
|
||||
expect(shouldClaimToastSwipe(g(3))).toBe(false);
|
||||
expect(shouldClaimToastSwipe(g(10, 10))).toBe(false);
|
||||
expect(shouldClaimToastSwipe(g(0, 30))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toastSwipeRelease', () => {
|
||||
it('dismisses past 35% of the card, towards the drag', () => {
|
||||
expect(toastSwipeRelease(g(120), 300)).toBe(1);
|
||||
expect(toastSwipeRelease(g(-110), 300)).toBe(-1);
|
||||
expect(toastSwipeRelease(g(100), 300)).toBe(0);
|
||||
});
|
||||
|
||||
it('caps the distance on wide cards', () => {
|
||||
expect(toastSwipeRelease(g(125), 800)).toBe(1);
|
||||
expect(toastSwipeRelease(g(115), 800)).toBe(0);
|
||||
});
|
||||
|
||||
it('dismisses a short flick', () => {
|
||||
expect(toastSwipeRelease(g(40, 0, 0.8), 300)).toBe(1);
|
||||
expect(toastSwipeRelease(g(-40, 0, -0.8), 300)).toBe(-1);
|
||||
// A flick against the drag does not count.
|
||||
expect(toastSwipeRelease(g(40, 0, -0.8), 300)).toBe(0);
|
||||
expect(toastSwipeRelease(g(20, 0, 2), 300)).toBe(0);
|
||||
});
|
||||
|
||||
it('does not dismiss a small drag before the card has a width', () => {
|
||||
expect(toastSwipeRelease(g(20), 0)).toBe(0);
|
||||
expect(toastSwipeRelease(g(130), 0)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,8 @@ interface AgendaViewProps {
|
||||
/** Reports the first day in view as the user scrolls. */
|
||||
onVisibleDateChange?: (date: Date) => void;
|
||||
timeFormat?: TimeFormat;
|
||||
/** The user's addresses, to draw events they declined as inactive. */
|
||||
currentUserEmails?: string[];
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
@@ -90,6 +92,7 @@ export function AgendaView({
|
||||
isLoading = false,
|
||||
onVisibleDateChange,
|
||||
timeFormat,
|
||||
currentUserEmails,
|
||||
onSelectEvent,
|
||||
}: AgendaViewProps) {
|
||||
const c = useColors();
|
||||
@@ -324,7 +327,13 @@ export function AgendaView({
|
||||
}}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.itemWrap}>
|
||||
<EventCard event={item} calendars={calendars} timeFormat={timeFormat} onPress={onSelectEvent} />
|
||||
<EventCard
|
||||
event={item}
|
||||
calendars={calendars}
|
||||
timeFormat={timeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
onPress={onSelectEvent}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
renderSectionFooter={({ section }) =>
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
||||
import type { EventBlockColors } from '../../lib/event-colors';
|
||||
|
||||
// Events in the week and day grids, shared by the paged WeekView and the
|
||||
// scrolling TimeGridScrollView. Every event is a solid block of its calendar
|
||||
// colour with a label colour computed from it; declined and cancelled ones
|
||||
// are outlined on the page ground with a struck-through title
|
||||
// (repos/branding/APP.md, "Calendar events"). Callers get the colours from
|
||||
// eventBlockColors().
|
||||
|
||||
/** Corner radius of event blocks and bars. */
|
||||
export const EVENT_RADIUS = 2;
|
||||
|
||||
/** Timed blocks this long or longer show their start time under the title. */
|
||||
export const TIME_LINE_MIN_MINUTES = 45;
|
||||
|
||||
// Corners on a side the event continues past (the day before or after, the
|
||||
// week before or after) stay square.
|
||||
function corners(
|
||||
axis: 'vertical' | 'horizontal',
|
||||
continuesBefore: boolean,
|
||||
continuesAfter: boolean,
|
||||
r: number,
|
||||
): ViewStyle {
|
||||
const before = continuesBefore ? 0 : r;
|
||||
const after = continuesAfter ? 0 : r;
|
||||
return axis === 'vertical'
|
||||
? {
|
||||
borderTopLeftRadius: before,
|
||||
borderTopRightRadius: before,
|
||||
borderBottomLeftRadius: after,
|
||||
borderBottomRightRadius: after,
|
||||
}
|
||||
: {
|
||||
borderTopLeftRadius: before,
|
||||
borderBottomLeftRadius: before,
|
||||
borderTopRightRadius: after,
|
||||
borderBottomRightRadius: after,
|
||||
};
|
||||
}
|
||||
|
||||
interface TimedEventBlockProps {
|
||||
title: string;
|
||||
/** Start time for the second line; null leaves it out. */
|
||||
timeLabel: string | null;
|
||||
colors: EventBlockColors;
|
||||
/** Page ground: a 1px ring in it keeps overlapping blocks apart. */
|
||||
ringColor: string;
|
||||
/** The event started on an earlier day (square top corners). */
|
||||
continuesBefore: boolean;
|
||||
/** The event runs into the next day (square bottom corners). */
|
||||
continuesAfter: boolean;
|
||||
/** Position and size in the day column. */
|
||||
style: StyleProp<ViewStyle>;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
/** A timed event in a day column. */
|
||||
export function TimedEventBlock({
|
||||
title,
|
||||
timeLabel,
|
||||
colors,
|
||||
ringColor,
|
||||
continuesBefore,
|
||||
continuesAfter,
|
||||
style,
|
||||
onPress,
|
||||
}: TimedEventBlockProps) {
|
||||
const inactive = colors.border !== null;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.block,
|
||||
corners('vertical', continuesBefore, continuesAfter, EVENT_RADIUS),
|
||||
{ backgroundColor: colors.fill, borderColor: ringColor },
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{inactive && (
|
||||
// The calendar-coloured outline sits inside the ring.
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
styles.outline,
|
||||
corners('vertical', continuesBefore, continuesAfter, EVENT_RADIUS - 1),
|
||||
{ borderColor: colors.border ?? undefined },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
style={[styles.blockTitle, { color: colors.text }, inactive && styles.struck]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{timeLabel !== null && (
|
||||
<Text style={[styles.blockTime, { color: colors.text }]} numberOfLines={1}>
|
||||
{timeLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface AllDayEventBarProps {
|
||||
title: string;
|
||||
colors: EventBlockColors;
|
||||
/** The event runs in from the week before (square left corners). */
|
||||
continuesBefore: boolean;
|
||||
/** The event runs on into the week after (square right corners). */
|
||||
continuesAfter: boolean;
|
||||
/** Position and size in the all-day strip. */
|
||||
style: StyleProp<ViewStyle>;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
/** An all-day or multi-day event in the strip above the grid. */
|
||||
export function AllDayEventBar({
|
||||
title,
|
||||
colors,
|
||||
continuesBefore,
|
||||
continuesAfter,
|
||||
style,
|
||||
onPress,
|
||||
}: AllDayEventBarProps) {
|
||||
const inactive = colors.border !== null;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.bar,
|
||||
corners('horizontal', continuesBefore, continuesAfter, EVENT_RADIUS),
|
||||
{ backgroundColor: colors.fill },
|
||||
inactive && [styles.barInactive, { borderColor: colors.border ?? undefined }],
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.barTitle, { color: colors.text }]} numberOfLines={1}>
|
||||
{continuesBefore ? '… ' : ''}
|
||||
<Text style={inactive && styles.struck}>{title}</Text>
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
block: {
|
||||
position: 'absolute',
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 3,
|
||||
paddingVertical: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
outline: { ...StyleSheet.absoluteFillObject, borderWidth: 1 },
|
||||
blockTitle: { fontSize: 12, lineHeight: 15, fontWeight: '500' },
|
||||
blockTime: { fontSize: 10.5, lineHeight: 13, fontWeight: '400', opacity: 0.85 },
|
||||
struck: { textDecorationLine: 'line-through' },
|
||||
bar: {
|
||||
position: 'absolute',
|
||||
paddingHorizontal: 4,
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// The outline takes the place of a pixel of padding.
|
||||
barInactive: { borderWidth: 1, paddingHorizontal: 3 },
|
||||
barTitle: { fontSize: 12, lineHeight: 15, fontWeight: '500' },
|
||||
});
|
||||
@@ -12,11 +12,14 @@ import {
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
import { isInactiveEvent } from '../../lib/calendar-participants';
|
||||
|
||||
interface EventCardProps {
|
||||
event: CalendarEvent;
|
||||
calendars: Calendar[];
|
||||
timeFormat?: TimeFormat;
|
||||
/** The user's addresses, to draw events they declined as inactive. */
|
||||
currentUserEmails?: string[];
|
||||
onPress?: (event: CalendarEvent) => void;
|
||||
onLongPress?: (event: CalendarEvent) => void;
|
||||
}
|
||||
@@ -25,7 +28,12 @@ function participantCount(event: CalendarEvent): number {
|
||||
return event.participants ? Object.keys(event.participants).length : 0;
|
||||
}
|
||||
|
||||
export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }: EventCardProps) {
|
||||
/**
|
||||
* An event as an agenda row (repos/branding/APP.md): a dot in the calendar
|
||||
* colour before the title, no card and no bar. Declined and cancelled events
|
||||
* get a hollow dot and a struck-through, muted title.
|
||||
*/
|
||||
export function EventCard({ event, calendars, timeFormat, currentUserEmails, onPress, onLongPress }: EventCardProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const { locale, t } = useCalendarLocale();
|
||||
@@ -37,18 +45,18 @@ export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }
|
||||
: `${format(start, fmt, { locale })} – ${format(end, fmt, { locale })}`;
|
||||
const count = participantCount(event);
|
||||
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
|
||||
const cancelled = event.status === 'cancelled';
|
||||
const inactive = isInactiveEvent(event, currentUserEmails);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => onPress?.(event)}
|
||||
onLongPress={() => onLongPress?.(event)}
|
||||
>
|
||||
<View style={[styles.colorBar, { backgroundColor: color }]} />
|
||||
<View style={[styles.dot, inactive ? [styles.dotHollow, { borderColor: color }] : { backgroundColor: color }]} />
|
||||
<View style={styles.body}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={[styles.title, cancelled && styles.titleCancelled]} numberOfLines={1}>
|
||||
<Text style={[styles.title, inactive && styles.titleInactive]} numberOfLines={1}>
|
||||
{event.title || t('calendar.events.no_title', '(No title)')}
|
||||
</Text>
|
||||
{event.showWithoutTime && (
|
||||
@@ -89,21 +97,31 @@ export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }
|
||||
);
|
||||
}
|
||||
|
||||
const EVENT_DOT = 9;
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
card: {
|
||||
// Bleeds into the list's side padding so the dot lines up with the day
|
||||
// headers while the pressed highlight keeps some room around the text.
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: c.card,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderBottomRightRadius: radius.lg,
|
||||
marginBottom: spacing.sm,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
marginHorizontal: -spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
cardPressed: { backgroundColor: c.surfaceHover },
|
||||
colorBar: { width: 3 },
|
||||
body: { flex: 1, padding: spacing.md, gap: 4 },
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
// Centred on the title's line (bodyMedium, 20px).
|
||||
dot: {
|
||||
width: EVENT_DOT,
|
||||
height: EVENT_DOT,
|
||||
borderRadius: EVENT_DOT / 2,
|
||||
marginTop: (typography.bodyMedium.lineHeight - EVENT_DOT) / 2,
|
||||
},
|
||||
dotHollow: { borderWidth: 1.5 },
|
||||
body: { flex: 1, gap: 4 },
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
@@ -111,7 +129,7 @@ function makeStyles(c: ThemePalette) {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
title: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||
titleCancelled: { textDecorationLine: 'line-through', color: c.textMuted },
|
||||
titleInactive: { textDecorationLine: 'line-through', color: c.mutedForeground },
|
||||
allDayBadge: {
|
||||
backgroundColor: c.primaryBg,
|
||||
paddingHorizontal: 8,
|
||||
|
||||
@@ -53,6 +53,8 @@ interface MonthScrollViewProps {
|
||||
showWeekNumbers?: boolean;
|
||||
showTimeInMonthView?: boolean;
|
||||
timeFormat?: TimeFormat;
|
||||
/** The user's addresses, to draw events they declined as inactive. */
|
||||
currentUserEmails?: string[];
|
||||
/** Widen the window at the top; omitted once the limit is reached. */
|
||||
onExtendStart?: () => void;
|
||||
/** Widen the window at the bottom; omitted once the limit is reached. */
|
||||
@@ -83,6 +85,7 @@ function MonthScrollViewInner({
|
||||
showWeekNumbers = false,
|
||||
showTimeInMonthView = false,
|
||||
timeFormat,
|
||||
currentUserEmails,
|
||||
onExtendStart,
|
||||
onExtendEnd,
|
||||
onVisibleDateChange,
|
||||
@@ -191,6 +194,7 @@ function MonthScrollViewInner({
|
||||
showWeekNumbers={showWeekNumbers}
|
||||
showTimeInMonthView={showTimeInMonthView}
|
||||
timeFormat={timeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
labelMonths
|
||||
height={rowHeight}
|
||||
locale={locale}
|
||||
@@ -209,6 +213,7 @@ function MonthScrollViewInner({
|
||||
showWeekNumbers,
|
||||
showTimeInMonthView,
|
||||
timeFormat,
|
||||
currentUserEmails,
|
||||
rowHeight,
|
||||
locale,
|
||||
styles,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type Locale,
|
||||
} from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { componentSizes, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
buildEventDayIndex,
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { dayIndexIn, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll';
|
||||
import { isInactiveEvent } from '../../lib/calendar-participants';
|
||||
import { eventBlockColors } from '../../lib/event-colors';
|
||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
import { displayNow } from '../../lib/calendar-timezone';
|
||||
|
||||
@@ -33,6 +35,8 @@ type WeekStart = 0 | 1 | 6;
|
||||
// scrolling month uses them as fixed row heights.
|
||||
export const MONTH_ROW_HEIGHT = 54;
|
||||
export const MONTH_ROW_HEIGHT_CHIPS = 74;
|
||||
// Corner radius of event bars (repos/branding/APP.md).
|
||||
const EVENT_RADIUS = 2;
|
||||
|
||||
interface MonthViewProps {
|
||||
currentDate: Date;
|
||||
@@ -45,6 +49,8 @@ interface MonthViewProps {
|
||||
// Render compact event chips (title + start time) instead of dots (#666).
|
||||
showTimeInMonthView?: boolean;
|
||||
timeFormat?: TimeFormat;
|
||||
/** The user's addresses, to draw events they declined as inactive. */
|
||||
currentUserEmails?: string[];
|
||||
onSelectDate: (date: Date) => void;
|
||||
onLongPressDate?: (date: Date) => void;
|
||||
}
|
||||
@@ -101,6 +107,8 @@ export interface MonthWeekRowProps {
|
||||
showWeekNumbers: boolean;
|
||||
showTimeInMonthView: boolean;
|
||||
timeFormat?: TimeFormat;
|
||||
/** The user's addresses, to draw events they declined as inactive. */
|
||||
currentUserEmails?: string[];
|
||||
/** Mark the first day of a month with the month's name (continuous scrolling). */
|
||||
labelMonths?: boolean;
|
||||
/** Fixed row height (continuous scrolling); natural height otherwise. */
|
||||
@@ -122,6 +130,7 @@ function MonthWeekRowInner({
|
||||
showWeekNumbers,
|
||||
showTimeInMonthView,
|
||||
timeFormat,
|
||||
currentUserEmails,
|
||||
labelMonths = false,
|
||||
height,
|
||||
locale,
|
||||
@@ -129,6 +138,7 @@ function MonthWeekRowInner({
|
||||
onSelectDate,
|
||||
onLongPressDate,
|
||||
}: MonthWeekRowProps) {
|
||||
const c = useColors();
|
||||
return (
|
||||
<View style={[styles.weekRow, height !== undefined && { height, overflow: 'hidden' }]}>
|
||||
{showWeekNumbers && (
|
||||
@@ -197,17 +207,24 @@ function MonthWeekRowInner({
|
||||
{dayEvents.length > 0 && showTimeInMonthView && (
|
||||
<View style={styles.chipsCol}>
|
||||
{visible.map((event, idx) => {
|
||||
const color = getEventColor(event, calendars);
|
||||
const cancelled = event.status === 'cancelled';
|
||||
// Solid bars in the calendar colour; declined and
|
||||
// cancelled events outlined (repos/branding/APP.md).
|
||||
const inactive = isInactiveEvent(event, currentUserEmails);
|
||||
const colors = eventBlockColors(getEventColor(event, calendars), inactive, c);
|
||||
return (
|
||||
<View key={`${event.id}-${idx}`} style={[styles.chip, { backgroundColor: color }]}>
|
||||
<Text
|
||||
style={[styles.chipText, cancelled && styles.chipTextCancelled]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
<View
|
||||
key={`${event.id}-${idx}`}
|
||||
style={[
|
||||
styles.chip,
|
||||
{ backgroundColor: colors.fill },
|
||||
colors.border !== null && [styles.chipInactive, { borderColor: colors.border }],
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.chipText, { color: colors.text }]} numberOfLines={1}>
|
||||
{event.showWithoutTime
|
||||
? (event.title || '')
|
||||
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ${event.title || ''}`}
|
||||
? null
|
||||
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `}
|
||||
<Text style={inactive && styles.chipTextInactive}>{event.title || ''}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -236,6 +253,7 @@ function MonthViewInner({
|
||||
showWeekNumbers = false,
|
||||
showTimeInMonthView = false,
|
||||
timeFormat,
|
||||
currentUserEmails,
|
||||
onSelectDate,
|
||||
onLongPressDate,
|
||||
}: MonthViewProps) {
|
||||
@@ -278,6 +296,7 @@ function MonthViewInner({
|
||||
showWeekNumbers={showWeekNumbers}
|
||||
showTimeInMonthView={showTimeInMonthView}
|
||||
timeFormat={timeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
locale={locale}
|
||||
styles={styles}
|
||||
onSelectDate={onSelectDate}
|
||||
@@ -349,12 +368,17 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
chipsCol: { width: '100%', gap: 1, marginTop: 2, alignItems: 'stretch' },
|
||||
chip: {
|
||||
borderRadius: radius.xs,
|
||||
borderRadius: EVENT_RADIUS,
|
||||
paddingHorizontal: 2,
|
||||
paddingVertical: 1,
|
||||
},
|
||||
chipText: { color: c.textInverse, fontSize: 9, lineHeight: 11 },
|
||||
chipTextCancelled: { textDecorationLine: 'line-through' },
|
||||
// The 1px outline takes the place of a pixel of padding, so outlined and
|
||||
// solid bars are the same size.
|
||||
chipInactive: { borderWidth: 1, paddingHorizontal: 1, paddingVertical: 0 },
|
||||
// Colour comes from eventBlockColors(): computed from the calendar colour,
|
||||
// never a theme colour.
|
||||
chipText: { fontSize: 9, lineHeight: 11, fontWeight: '500' },
|
||||
chipTextInactive: { textDecorationLine: 'line-through' },
|
||||
overflowText: {
|
||||
color: c.textMuted,
|
||||
fontSize: 9,
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { differenceInCalendarDays, format, type Locale } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
import {
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
} from '../../lib/calendar-utils';
|
||||
import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window';
|
||||
import { displayNow, displayNowMinutes } from '../../lib/calendar-timezone';
|
||||
import { isInactiveEvent } from '../../lib/calendar-participants';
|
||||
import { eventBlockColors } from '../../lib/event-colors';
|
||||
import {
|
||||
buildAllDaySegments,
|
||||
headerColumnRange,
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
windowDays,
|
||||
type TimeGridMode,
|
||||
} from '../../lib/calendar-time-grid';
|
||||
import { AllDayEventBar, TIME_LINE_MIN_MINUTES, TimedEventBlock } from './EventBlock';
|
||||
|
||||
const HOUR_HEIGHT = 48;
|
||||
const GRID_HEIGHT = 24 * HOUR_HEIGHT;
|
||||
@@ -67,6 +70,8 @@ interface TimeGridScrollViewProps {
|
||||
eventsByDay?: EventDayIndex;
|
||||
weekStartsOn?: WeekStart;
|
||||
timeFormat?: TimeFormat;
|
||||
/** The user's addresses, to draw events they declined as inactive. */
|
||||
currentUserEmails?: string[];
|
||||
/** Widen the window at the start; omitted once the limit is reached. */
|
||||
onExtendStart?: () => void;
|
||||
/** Widen the window at the end; omitted once the limit is reached. */
|
||||
@@ -98,6 +103,7 @@ function TimeGridScrollViewInner({
|
||||
eventsByDay,
|
||||
weekStartsOn = 0,
|
||||
timeFormat = '24h',
|
||||
currentUserEmails,
|
||||
onExtendStart,
|
||||
onExtendEnd,
|
||||
onVisibleDateChange,
|
||||
@@ -301,12 +307,17 @@ function TimeGridScrollViewInner({
|
||||
nowMinutes={dayKey(day) === todayKey ? nowMinutes : -1}
|
||||
calendars={calendars}
|
||||
timeFormat={timeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
colors={c}
|
||||
styles={styles}
|
||||
onSelectEvent={onSelectEvent}
|
||||
onLongPressAt={onCreateAtTime ? handleLongPressAt : undefined}
|
||||
/>
|
||||
),
|
||||
[layoutsFor, colWidth, todayKey, nowMinutes, calendars, timeFormat, styles, onSelectEvent, onCreateAtTime, handleLongPressAt],
|
||||
[
|
||||
layoutsFor, colWidth, todayKey, nowMinutes, calendars, timeFormat, currentUserEmails, c, styles,
|
||||
onSelectEvent, onCreateAtTime, handleLongPressAt,
|
||||
],
|
||||
);
|
||||
|
||||
const getItemLayout = React.useCallback(
|
||||
@@ -379,34 +390,26 @@ function TimeGridScrollViewInner({
|
||||
]}
|
||||
>
|
||||
{headerCells}
|
||||
{visibleSegments.map((segment) => {
|
||||
const color = getEventColor(segment.event, calendars);
|
||||
return (
|
||||
<Pressable
|
||||
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
|
||||
onPress={() => onSelectEvent?.(segment.event)}
|
||||
style={[
|
||||
styles.allDayChip,
|
||||
{
|
||||
left: segment.startIndex * colWidth + 1,
|
||||
width: segment.span * colWidth - 2,
|
||||
top: HEADER_HEIGHT + 2 + segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP),
|
||||
backgroundColor: color + '33',
|
||||
borderLeftColor: color,
|
||||
borderTopLeftRadius: segment.continuesBefore ? 0 : radius.xs,
|
||||
borderBottomLeftRadius: segment.continuesBefore ? 0 : radius.xs,
|
||||
borderTopRightRadius: segment.continuesAfter ? 0 : radius.xs,
|
||||
borderBottomRightRadius: segment.continuesAfter ? 0 : radius.xs,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.allDayChipText} numberOfLines={1}>
|
||||
{segment.continuesBefore ? '… ' : ''}
|
||||
{segment.event.title || t('calendar.events.no_title', '(No title)')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{visibleSegments.map((segment) => (
|
||||
<AllDayEventBar
|
||||
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
|
||||
title={segment.event.title || t('calendar.events.no_title', '(No title)')}
|
||||
colors={eventBlockColors(
|
||||
getEventColor(segment.event, calendars),
|
||||
isInactiveEvent(segment.event, currentUserEmails),
|
||||
c,
|
||||
)}
|
||||
continuesBefore={segment.continuesBefore}
|
||||
continuesAfter={segment.continuesAfter}
|
||||
onPress={() => onSelectEvent?.(segment.event)}
|
||||
style={{
|
||||
left: segment.startIndex * colWidth + 1,
|
||||
width: segment.span * colWidth - 2,
|
||||
top: HEADER_HEIGHT + 2 + segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP),
|
||||
height: ALL_DAY_CHIP_HEIGHT,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -526,6 +529,8 @@ const DayColumn = React.memo(function DayColumn({
|
||||
nowMinutes,
|
||||
calendars,
|
||||
timeFormat,
|
||||
currentUserEmails,
|
||||
colors,
|
||||
styles,
|
||||
onSelectEvent,
|
||||
onLongPressAt,
|
||||
@@ -537,6 +542,8 @@ const DayColumn = React.memo(function DayColumn({
|
||||
nowMinutes: number;
|
||||
calendars: Calendar[];
|
||||
timeFormat: TimeFormat;
|
||||
currentUserEmails?: string[];
|
||||
colors: ThemePalette;
|
||||
styles: GridStyles;
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
onLongPressAt?: (day: Date, hour: number) => void;
|
||||
@@ -553,36 +560,28 @@ const DayColumn = React.memo(function DayColumn({
|
||||
style={[styles.dayCol, { width }]}
|
||||
onLongPress={onLongPressAt ? handleLongPress : undefined}
|
||||
>
|
||||
{layouts.map(({ event, column, totalColumns, startMinutes, endMinutes }) => {
|
||||
{layouts.map(({ event, column, totalColumns, startMinutes, endMinutes, continuesBefore, continuesAfter }) => {
|
||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const height = Math.max(20, ((endMinutes - startMinutes) / 60) * HOUR_HEIGHT - 1);
|
||||
const widthPct = 100 / totalColumns;
|
||||
const color = getEventColor(event, calendars);
|
||||
return (
|
||||
<Pressable
|
||||
<TimedEventBlock
|
||||
key={event.id}
|
||||
onPress={() => onSelectEvent?.(event)}
|
||||
style={[
|
||||
styles.eventBlock,
|
||||
{
|
||||
top,
|
||||
height,
|
||||
left: `${column * widthPct}%`,
|
||||
width: `${widthPct}%`,
|
||||
backgroundColor: color + '33',
|
||||
borderLeftColor: color,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.eventBlockTitle} numberOfLines={1}>
|
||||
{event.title || t('calendar.events.no_title', '(No title)')}
|
||||
</Text>
|
||||
{height > 32 && (
|
||||
<Text style={styles.eventBlockTime} numberOfLines={1}>
|
||||
{minutesToTimeLabel(startMinutes, timeFormat)}
|
||||
</Text>
|
||||
title={event.title || t('calendar.events.no_title', '(No title)')}
|
||||
timeLabel={endMinutes - startMinutes >= TIME_LINE_MIN_MINUTES
|
||||
? minutesToTimeLabel(startMinutes, timeFormat)
|
||||
: null}
|
||||
colors={eventBlockColors(
|
||||
getEventColor(event, calendars),
|
||||
isInactiveEvent(event, currentUserEmails),
|
||||
colors,
|
||||
)}
|
||||
</Pressable>
|
||||
ringColor={colors.background}
|
||||
continuesBefore={continuesBefore}
|
||||
continuesAfter={continuesAfter}
|
||||
onPress={() => onSelectEvent?.(event)}
|
||||
style={{ top, height, left: `${column * widthPct}%`, width: `${widthPct}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{nowMinutes >= 0 && (
|
||||
@@ -649,14 +648,6 @@ function makeStyles(c: ThemePalette) {
|
||||
|
||||
allDayLabelWrap: { alignItems: 'flex-end', justifyContent: 'flex-start', paddingRight: 4, paddingTop: 2 },
|
||||
allDayLabel: { ...typography.small, color: c.textMuted, textAlign: 'right' },
|
||||
allDayChip: {
|
||||
position: 'absolute',
|
||||
height: ALL_DAY_CHIP_HEIGHT,
|
||||
borderLeftWidth: 2,
|
||||
paddingHorizontal: 4,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
allDayChipText: { fontSize: 10, color: c.text, fontWeight: '500' },
|
||||
|
||||
vScroll: { flex: 1 },
|
||||
gridRow: { flexDirection: 'row', height: GRID_HEIGHT },
|
||||
@@ -678,16 +669,6 @@ function makeStyles(c: ThemePalette) {
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: c.border,
|
||||
},
|
||||
eventBlock: {
|
||||
position: 'absolute',
|
||||
borderRadius: radius.xs,
|
||||
borderLeftWidth: 2,
|
||||
paddingHorizontal: 3,
|
||||
paddingVertical: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
eventBlockTitle: { fontSize: 10, color: c.text, fontWeight: '600' },
|
||||
eventBlockTime: { fontSize: 9, color: c.textMuted, marginTop: 1 },
|
||||
nowLine: {
|
||||
position: 'absolute',
|
||||
left: -3,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { addDays, format, isSameDay, startOfWeek } from 'date-fns';
|
||||
import { displayNow, displayNowMinutes, isDisplayToday } from '../../lib/calendar-timezone';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
import {
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
type CalendarWeekSegment,
|
||||
type EventDayIndex,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { isInactiveEvent } from '../../lib/calendar-participants';
|
||||
import { eventBlockColors } from '../../lib/event-colors';
|
||||
import { AllDayEventBar, TIME_LINE_MIN_MINUTES, TimedEventBlock } from './EventBlock';
|
||||
|
||||
const HOUR_HEIGHT = 48;
|
||||
const GUTTER_WIDTH = 44;
|
||||
@@ -43,6 +46,8 @@ interface WeekViewProps {
|
||||
onCreateAtTime?: (date: Date) => void;
|
||||
weekStartsOn?: 0 | 1 | 6;
|
||||
timeFormat?: '12h' | '24h';
|
||||
/** The user's addresses, to draw events they declined as inactive. */
|
||||
currentUserEmails?: string[];
|
||||
}
|
||||
|
||||
function WeekViewInner({
|
||||
@@ -56,6 +61,7 @@ function WeekViewInner({
|
||||
onCreateAtTime,
|
||||
weekStartsOn = 0,
|
||||
timeFormat = '24h',
|
||||
currentUserEmails,
|
||||
}: WeekViewProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -183,35 +189,28 @@ function WeekViewInner({
|
||||
{/* Segments overlay - multi-day events span their date range */}
|
||||
<View style={styles.allDayOverlay} pointerEvents="box-none">
|
||||
{allDaySegments.map((segment) => {
|
||||
const color = getEventColor(segment.event, calendars);
|
||||
const leftPct = (segment.startIndex / weekDays.length) * 100;
|
||||
const widthPct = (segment.span / weekDays.length) * 100;
|
||||
return (
|
||||
<Pressable
|
||||
<AllDayEventBar
|
||||
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
|
||||
title={segment.event.title || t('calendar.events.no_title', '(No title)')}
|
||||
colors={eventBlockColors(
|
||||
getEventColor(segment.event, calendars),
|
||||
isInactiveEvent(segment.event, currentUserEmails),
|
||||
c,
|
||||
)}
|
||||
continuesBefore={segment.continuesBefore}
|
||||
continuesAfter={segment.continuesAfter}
|
||||
onPress={() => onSelectEvent?.(segment.event)}
|
||||
style={[
|
||||
styles.allDayChip,
|
||||
{
|
||||
position: 'absolute',
|
||||
left: `${leftPct}%`,
|
||||
width: `${widthPct}%`,
|
||||
top: segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP),
|
||||
backgroundColor: color + '33',
|
||||
borderLeftColor: color,
|
||||
borderTopLeftRadius: segment.continuesBefore ? 0 : radius.xs,
|
||||
borderBottomLeftRadius: segment.continuesBefore ? 0 : radius.xs,
|
||||
borderTopRightRadius: segment.continuesAfter ? 0 : radius.xs,
|
||||
borderBottomRightRadius: segment.continuesAfter ? 0 : radius.xs,
|
||||
marginHorizontal: 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.allDayChipText} numberOfLines={1}>
|
||||
{segment.continuesBefore ? '… ' : ''}
|
||||
{segment.event.title || t('calendar.events.no_title', '(No title)')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
style={{
|
||||
left: `${leftPct}%`,
|
||||
width: `${widthPct}%`,
|
||||
top: segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP),
|
||||
height: ALL_DAY_CHIP_HEIGHT,
|
||||
marginHorizontal: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
@@ -255,7 +254,7 @@ function WeekViewInner({
|
||||
))}
|
||||
|
||||
{layouted.map(
|
||||
({ event, column, totalColumns, startMinutes, endMinutes }) => {
|
||||
({ event, column, totalColumns, startMinutes, endMinutes, continuesBefore, continuesAfter }) => {
|
||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const height = Math.max(
|
||||
20,
|
||||
@@ -263,32 +262,24 @@ function WeekViewInner({
|
||||
);
|
||||
const widthPct = 100 / totalColumns;
|
||||
const leftPct = column * widthPct;
|
||||
const color = getEventColor(event, calendars);
|
||||
return (
|
||||
<Pressable
|
||||
<TimedEventBlock
|
||||
key={event.id}
|
||||
onPress={() => onSelectEvent?.(event)}
|
||||
style={[
|
||||
styles.eventBlock,
|
||||
{
|
||||
top,
|
||||
height,
|
||||
left: `${leftPct}%`,
|
||||
width: `${widthPct}%`,
|
||||
backgroundColor: color + '33',
|
||||
borderLeftColor: color,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.eventBlockTitle} numberOfLines={1}>
|
||||
{event.title || t('calendar.events.no_title', '(No title)')}
|
||||
</Text>
|
||||
{height > 32 && (
|
||||
<Text style={styles.eventBlockTime} numberOfLines={1}>
|
||||
{minutesToTimeLabel(startMinutes, timeFormat)}
|
||||
</Text>
|
||||
title={event.title || t('calendar.events.no_title', '(No title)')}
|
||||
timeLabel={endMinutes - startMinutes >= TIME_LINE_MIN_MINUTES
|
||||
? minutesToTimeLabel(startMinutes, timeFormat)
|
||||
: null}
|
||||
colors={eventBlockColors(
|
||||
getEventColor(event, calendars),
|
||||
isInactiveEvent(event, currentUserEmails),
|
||||
c,
|
||||
)}
|
||||
</Pressable>
|
||||
ringColor={c.background}
|
||||
continuesBefore={continuesBefore}
|
||||
continuesAfter={continuesAfter}
|
||||
onPress={() => onSelectEvent?.(event)}
|
||||
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%` }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
@@ -389,14 +380,6 @@ function makeStyles(c: ThemePalette) {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
allDayChip: {
|
||||
height: ALL_DAY_CHIP_HEIGHT,
|
||||
borderRadius: radius.xs,
|
||||
borderLeftWidth: 2,
|
||||
paddingHorizontal: 4,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
allDayChipText: { fontSize: 10, color: c.text, fontWeight: '500' },
|
||||
|
||||
scroll: { flex: 1 },
|
||||
scrollContent: {},
|
||||
@@ -419,16 +402,6 @@ function makeStyles(c: ThemePalette) {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
eventBlock: {
|
||||
position: 'absolute',
|
||||
borderRadius: radius.xs,
|
||||
borderLeftWidth: 2,
|
||||
paddingHorizontal: 3,
|
||||
paddingVertical: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
eventBlockTitle: { fontSize: 10, color: c.text, fontWeight: '600' },
|
||||
eventBlockTime: { fontSize: 9, color: c.textMuted, marginTop: 1 },
|
||||
|
||||
nowLine: {
|
||||
position: 'absolute',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Pure decisions behind the toast's swipe-to-dismiss. On the phone a toast has
|
||||
// no close button; swiping it sideways dismisses it (repos/branding/APP.md).
|
||||
// Like swipe-gesture.ts for list rows, this sits outside the component
|
||||
// because the PanResponder is built once: everything it decides on arrives as
|
||||
// an argument, so nothing can go stale.
|
||||
|
||||
/** The subset of RN's PanResponder gesture state this logic needs. */
|
||||
export interface ToastSwipeGesture {
|
||||
dx: number;
|
||||
dy: number;
|
||||
vx: number;
|
||||
}
|
||||
|
||||
const MIN_DX_TO_CLAIM = 6;
|
||||
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
|
||||
// A release past this share of the card's width dismisses it, capped so a
|
||||
// wide screen does not ask for a long drag.
|
||||
const DISMISS_FRACTION = 0.35;
|
||||
const MAX_DISMISS_DISTANCE = 120;
|
||||
// A short, fast flick dismisses too.
|
||||
const FLING_DX = 30;
|
||||
const FLING_VX = 0.5;
|
||||
|
||||
/** Whether a move is a sideways drag the toast should take over. */
|
||||
export function shouldClaimToastSwipe(g: ToastSwipeGesture): boolean {
|
||||
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
|
||||
return Math.abs(g.dx) >= Math.abs(g.dy) * DIRECTION_BIAS;
|
||||
}
|
||||
|
||||
/**
|
||||
* What releasing the drag does: 1 or -1 dismisses the toast towards that
|
||||
* side, 0 springs it back. `width` is the card's width (0 before layout).
|
||||
*/
|
||||
export function toastSwipeRelease(g: ToastSwipeGesture, width: number): 1 | -1 | 0 {
|
||||
const distance = width > 0 ? Math.min(MAX_DISMISS_DISTANCE, width * DISMISS_FRACTION) : MAX_DISMISS_DISTANCE;
|
||||
if (g.dx >= distance || (g.dx > FLING_DX && g.vx >= FLING_VX)) return 1;
|
||||
if (g.dx <= -distance || (g.dx < -FLING_DX && g.vx <= -FLING_VX)) return -1;
|
||||
return 0;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
getParticipantList,
|
||||
getStatusCounts,
|
||||
getUserParticipantId,
|
||||
isDeclinedByUser,
|
||||
isInactiveEvent,
|
||||
isOrganizer,
|
||||
seedAttendees,
|
||||
} from '../calendar-participants';
|
||||
@@ -171,3 +173,31 @@ describe('getParticipantList dedupe, names and organizer status', () => {
|
||||
})).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDeclinedByUser / isInactiveEvent', () => {
|
||||
const declined: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: { email: 'alice@example.com', roles: { owner: true } },
|
||||
dave: { calendarAddress: 'mailto:dave@example.com', roles: { attendee: true }, participationStatus: 'declined' },
|
||||
bob: { email: 'bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
|
||||
},
|
||||
};
|
||||
|
||||
it('matches any of the user addresses, case-insensitively', () => {
|
||||
expect(isDeclinedByUser(declined, ['Dave@Example.com'])).toBe(true);
|
||||
expect(isDeclinedByUser(declined, ['alias@example.com', 'dave@example.com'])).toBe(true);
|
||||
expect(isDeclinedByUser(declined, ['bob@example.com'])).toBe(false);
|
||||
});
|
||||
|
||||
it('is false without addresses or participants', () => {
|
||||
expect(isDeclinedByUser(declined, [])).toBe(false);
|
||||
expect(isDeclinedByUser(declined, undefined)).toBe(false);
|
||||
expect(isDeclinedByUser({}, ['dave@example.com'])).toBe(false);
|
||||
});
|
||||
|
||||
it('counts cancelled events as inactive too', () => {
|
||||
expect(isInactiveEvent({ status: 'cancelled' }, undefined)).toBe(true);
|
||||
expect(isInactiveEvent(declined, ['dave@example.com'])).toBe(true);
|
||||
expect(isInactiveEvent({ ...declined, status: 'confirmed' }, ['bob@example.com'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DARK_EVENT_TEXT,
|
||||
eventBlockColors,
|
||||
inactiveEventTextColor,
|
||||
mixColors,
|
||||
parseColor,
|
||||
readableTextOn,
|
||||
} from '../event-colors';
|
||||
|
||||
describe('parseColor', () => {
|
||||
it('reads the hex forms', () => {
|
||||
expect(parseColor('#abc')).toEqual({ r: 170, g: 187, b: 204, a: 1 });
|
||||
expect(parseColor('#3B82F6')).toEqual({ r: 59, g: 130, b: 246, a: 1 });
|
||||
expect(parseColor('#3b82f680')).toEqual({ r: 59, g: 130, b: 246, a: 128 / 255 });
|
||||
expect(parseColor('#abcd')?.a).toBeCloseTo(0xdd / 255);
|
||||
expect(parseColor(' #000000 ')).toEqual({ r: 0, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it('reads rgb() and rgba() in both syntaxes', () => {
|
||||
expect(parseColor('rgb(59, 130, 246)')).toEqual({ r: 59, g: 130, b: 246, a: 1 });
|
||||
expect(parseColor('rgba(59,130,246,0.5)')).toEqual({ r: 59, g: 130, b: 246, a: 0.5 });
|
||||
expect(parseColor('RGBA(128, 128, 128, .3)')).toEqual({ r: 128, g: 128, b: 128, a: 0.3 });
|
||||
expect(parseColor('rgb(59 130 246 / 50%)')).toEqual({ r: 59, g: 130, b: 246, a: 0.5 });
|
||||
expect(parseColor('rgb(100%, 0%, 0%)')).toEqual({ r: 255, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it('gives null for what it does not understand', () => {
|
||||
expect(parseColor('blue')).toBeNull();
|
||||
expect(parseColor('#12')).toBeNull();
|
||||
expect(parseColor('#12345')).toBeNull();
|
||||
expect(parseColor('rgb(1, 2)')).toBeNull();
|
||||
expect(parseColor('rgb(a, b, c)')).toBeNull();
|
||||
expect(parseColor('hsl(0, 100%, 50%)')).toBeNull();
|
||||
expect(parseColor('')).toBeNull();
|
||||
expect(parseColor(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readableTextOn', () => {
|
||||
it('keeps white while white reaches 3:1 on the fill', () => {
|
||||
expect(readableTextOn('#3b82f6')).toBe('#ffffff'); // blue-500, 3.68:1
|
||||
expect(readableTextOn('#ef4444')).toBe('#ffffff'); // red-500, 3.76:1
|
||||
expect(readableTextOn('#000')).toBe('#ffffff');
|
||||
expect(readableTextOn('rgb(99, 102, 241)')).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('switches to near-black on light fills', () => {
|
||||
expect(readableTextOn('#22c55e')).toBe(DARK_EVENT_TEXT); // green-500, 2.3:1
|
||||
expect(readableTextOn('#eab308')).toBe(DARK_EVENT_TEXT);
|
||||
expect(readableTextOn('#fbbf24')).toBe(DARK_EVENT_TEXT);
|
||||
expect(readableTextOn('#ffffff')).toBe(DARK_EVENT_TEXT);
|
||||
});
|
||||
|
||||
it('does not depend on the theme', () => {
|
||||
// Same fill, same answer: nothing here reads the palette.
|
||||
expect(readableTextOn('#60a5fa')).toBe(readableTextOn('#60A5FA'));
|
||||
});
|
||||
|
||||
it('falls back to white for colours it cannot read', () => {
|
||||
expect(readableTextOn('tomato')).toBe('#ffffff');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixColors', () => {
|
||||
it('mixes like color-mix(in srgb)', () => {
|
||||
expect(mixColors('#ff0000', '#0000ff', 0.55)).toBe('#8c0073');
|
||||
expect(mixColors('#ffffff', '#000000', 0.5)).toBe('#808080');
|
||||
expect(mixColors('#123456', '#abcdef', 1)).toBe('#123456');
|
||||
expect(mixColors('#123456', '#abcdef', 0)).toBe('#abcdef');
|
||||
});
|
||||
|
||||
it('interpolates alpha premultiplied', () => {
|
||||
expect(mixColors('rgba(255, 0, 0, 0.5)', '#0000ff', 0.5)).toBe('rgba(85, 0, 170, 0.75)');
|
||||
expect(mixColors('rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)', 0.5)).toBe('rgba(0, 0, 0, 0)');
|
||||
});
|
||||
|
||||
it('gives null when a colour cannot be read', () => {
|
||||
expect(mixColors('blue', '#000000', 0.5)).toBeNull();
|
||||
expect(mixColors('#000000', 'nope', 0.5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inactiveEventTextColor', () => {
|
||||
it('is the calendar colour mixed 55% with the text colour', () => {
|
||||
// 0.55 × #3b82f6 + 0.45 × #0f172a
|
||||
expect(inactiveEventTextColor('#3b82f6', '#0f172a')).toBe('#27529a');
|
||||
// 0.55 × #3b82f6 + 0.45 × #fafafa
|
||||
expect(inactiveEventTextColor('#3b82f6', '#fafafa')).toBe('#91b8f8');
|
||||
});
|
||||
|
||||
it('falls back to the text colour', () => {
|
||||
expect(inactiveEventTextColor('teal', '#0f172a')).toBe('#0f172a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventBlockColors', () => {
|
||||
const light = { background: '#ffffff', text: '#0f172a' };
|
||||
const dark = { background: '#09090b', text: '#fafafa' };
|
||||
|
||||
it('paints active events solid with the computed label colour in either theme', () => {
|
||||
const expected = { fill: '#3b82f6', text: '#ffffff', border: null };
|
||||
expect(eventBlockColors('#3b82f6', false, light)).toEqual(expected);
|
||||
expect(eventBlockColors('#3b82f6', false, dark)).toEqual(expected);
|
||||
expect(eventBlockColors('#22c55e', false, dark).text).toBe(DARK_EVENT_TEXT);
|
||||
});
|
||||
|
||||
it('puts declined and cancelled events on the page ground inside a calendar-coloured border', () => {
|
||||
expect(eventBlockColors('#3b82f6', true, light)).toEqual({
|
||||
fill: '#ffffff',
|
||||
text: '#27529a',
|
||||
border: '#3b82f6',
|
||||
});
|
||||
expect(eventBlockColors('#3b82f6', true, dark).fill).toBe('#09090b');
|
||||
});
|
||||
});
|
||||
@@ -127,6 +127,30 @@ export function getUserStatus(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the user declined this event. An occurrence carries its override's
|
||||
* participants, so one declined instance of an accepted series counts
|
||||
* (webmail #1110).
|
||||
*/
|
||||
export function isDeclinedByUser(
|
||||
event: Partial<CalendarEvent>,
|
||||
userEmails: string[] | undefined,
|
||||
): boolean {
|
||||
if (!userEmails || userEmails.length === 0) return false;
|
||||
return getUserStatus(event, userEmails) === 'declined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Declined by the user or cancelled by the organizer: the calendar draws both
|
||||
* the same way, as an outlined, struck-through event (repos/branding/APP.md).
|
||||
*/
|
||||
export function isInactiveEvent(
|
||||
event: Partial<CalendarEvent>,
|
||||
userEmails: string[] | undefined,
|
||||
): boolean {
|
||||
return event.status === 'cancelled' || isDeclinedByUser(event, userEmails);
|
||||
}
|
||||
|
||||
/** When the same address appears in two participant entries, a real RSVP on
|
||||
* either one beats a missing/"needs-action" one — the duplicate is always the
|
||||
* entry that never replied. Between two real replies the first-seen entry
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Colours for calendar events, which every view draws as a solid block of
|
||||
// the calendar colour (repos/branding/APP.md, "Calendar events"). The label
|
||||
// colour is computed from the fill rather than taken from the palette: a
|
||||
// fixed "inverse" text colour flips with the theme while the fill does not.
|
||||
// React Native has no color-mix(), so the declined/cancelled label colour is
|
||||
// mixed here too.
|
||||
|
||||
export interface Rgba {
|
||||
/** 0–255 */
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
/** 0–1 */
|
||||
a: number;
|
||||
}
|
||||
|
||||
/** Label colour on fills too light for white. */
|
||||
export const DARK_EVENT_TEXT = '#111827';
|
||||
const WHITE = '#ffffff';
|
||||
|
||||
/** Share of the calendar colour in a declined or cancelled event's label. */
|
||||
export const INACTIVE_TEXT_MIX = 0.55;
|
||||
|
||||
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
|
||||
|
||||
function hexByte(hex: string): number {
|
||||
return parseInt(hex.length === 1 ? hex + hex : hex, 16);
|
||||
}
|
||||
|
||||
// One rgb() channel: 0–255, or a percentage of 255.
|
||||
function channel(token: string): number | null {
|
||||
const pct = token.endsWith('%');
|
||||
const n = Number(pct ? token.slice(0, -1) : token);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return clamp(pct ? (n / 100) * 255 : n, 0, 255);
|
||||
}
|
||||
|
||||
// The alpha of rgba(): 0–1, or a percentage.
|
||||
function alpha(token: string): number | null {
|
||||
const pct = token.endsWith('%');
|
||||
const n = Number(pct ? token.slice(0, -1) : token);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return clamp(pct ? n / 100 : n, 0, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, and `rgb()` / `rgba()` in
|
||||
* both the comma and the space-separated syntax. Anything else (named
|
||||
* colours, hsl()) gives null.
|
||||
*/
|
||||
export function parseColor(input: string | null | undefined): Rgba | null {
|
||||
if (!input) return null;
|
||||
const s = input.trim().toLowerCase();
|
||||
|
||||
const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(s);
|
||||
if (hex) {
|
||||
const h = hex[1];
|
||||
const short = h.length <= 4;
|
||||
const parts = short ? h.split('') : h.match(/../g) ?? [];
|
||||
const [r, g, b, a] = parts.map(hexByte);
|
||||
return { r, g, b, a: a === undefined ? 1 : a / 255 };
|
||||
}
|
||||
|
||||
const fn = /^rgba?\(([^)]*)\)$/.exec(s);
|
||||
if (fn) {
|
||||
const body = fn[1].trim();
|
||||
let tokens: string[];
|
||||
let alphaToken: string | undefined;
|
||||
if (body.includes(',')) {
|
||||
tokens = body.split(',').map((t) => t.trim());
|
||||
if (tokens.length === 4) alphaToken = tokens.pop();
|
||||
} else {
|
||||
const [rgb, a] = body.split('/').map((t) => t.trim());
|
||||
tokens = rgb ? rgb.split(/\s+/) : [];
|
||||
alphaToken = a;
|
||||
}
|
||||
if (tokens.length !== 3) return null;
|
||||
const [r, g, b] = tokens.map(channel);
|
||||
const a = alphaToken === undefined ? 1 : alpha(alphaToken);
|
||||
if (r === null || g === null || b === null || a === null) return null;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** WCAG relative luminance of an sRGB colour (alpha ignored). */
|
||||
export function relativeLuminance({ r, g, b }: Rgba): number {
|
||||
const lin = (v: number) => {
|
||||
const c = v / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label colour for text on a solid event fill: white while white keeps 3:1
|
||||
* contrast with the fill, `#111827` on lighter fills. A colour that cannot be
|
||||
* parsed gets white, like the webmail's readableTextOn().
|
||||
*/
|
||||
export function readableTextOn(fill: string): string {
|
||||
const rgb = parseColor(fill);
|
||||
if (!rgb) return WHITE;
|
||||
const contrastWithWhite = 1.05 / (relativeLuminance(rgb) + 0.05);
|
||||
return contrastWithWhite >= 3 ? WHITE : DARK_EVENT_TEXT;
|
||||
}
|
||||
|
||||
function toHexByte(n: number): string {
|
||||
return Math.round(clamp(n, 0, 255)).toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** `#rrggbb` for an opaque colour, `rgba(…)` otherwise. */
|
||||
export function formatColor({ r, g, b, a }: Rgba): string {
|
||||
if (a >= 1) return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`;
|
||||
const round = (n: number) => Math.round(clamp(n, 0, 255));
|
||||
return `rgba(${round(r)}, ${round(g)}, ${round(b)}, ${Number(a.toFixed(3))})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS `color-mix(in srgb, a <weight>, b)`: `weight` (0–1) of `a`, the rest
|
||||
* of `b`, with premultiplied alpha as CSS does. Null when either colour
|
||||
* cannot be parsed.
|
||||
*/
|
||||
export function mixColors(a: string, b: string, weight: number): string | null {
|
||||
const ca = parseColor(a);
|
||||
const cb = parseColor(b);
|
||||
if (!ca || !cb) return null;
|
||||
const wa = clamp(weight, 0, 1);
|
||||
const wb = 1 - wa;
|
||||
const outA = ca.a * wa + cb.a * wb;
|
||||
if (outA === 0) return formatColor({ r: 0, g: 0, b: 0, a: 0 });
|
||||
const mix = (x: number, y: number) => (x * ca.a * wa + y * cb.a * wb) / outA;
|
||||
return formatColor({ r: mix(ca.r, cb.r), g: mix(ca.g, cb.g), b: mix(ca.b, cb.b), a: outA });
|
||||
}
|
||||
|
||||
/**
|
||||
* Label colour of a declined or cancelled event: the calendar colour mixed
|
||||
* 55% with the text colour. Falls back to the text colour when the calendar
|
||||
* colour cannot be parsed.
|
||||
*/
|
||||
export function inactiveEventTextColor(calendarColor: string, textColor: string): string {
|
||||
return mixColors(calendarColor, textColor, INACTIVE_TEXT_MIX) ?? textColor;
|
||||
}
|
||||
|
||||
export interface EventBlockColors {
|
||||
/** Block background. */
|
||||
fill: string;
|
||||
/** Title and time. */
|
||||
text: string;
|
||||
/** Inset border in the calendar colour, for declined and cancelled events only. */
|
||||
border: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill, label and border of an event block, bar or chip. Active events are
|
||||
* solid calendar colour with a computed label colour; declined and cancelled
|
||||
* ones sit on the page ground inside a calendar-coloured border.
|
||||
*/
|
||||
export function eventBlockColors(
|
||||
calendarColor: string,
|
||||
inactive: boolean,
|
||||
page: { background: string; text: string },
|
||||
): EventBlockColors {
|
||||
if (inactive) {
|
||||
return {
|
||||
fill: page.background,
|
||||
text: inactiveEventTextColor(calendarColor, page.text),
|
||||
border: calendarColor,
|
||||
};
|
||||
}
|
||||
return { fill: calendarColor, text: readableTextOn(calendarColor), border: null };
|
||||
}
|
||||
@@ -1050,6 +1050,7 @@ export default function CalendarScreen() {
|
||||
showWeekNumbers={calendarShowWeekNumbers}
|
||||
showTimeInMonthView={calendarShowTimeInMonth}
|
||||
timeFormat={calendarTimeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
onSelectDate={handleSelectDate}
|
||||
onLongPressDate={openCreate}
|
||||
/>
|
||||
@@ -1065,6 +1066,7 @@ export default function CalendarScreen() {
|
||||
showWeekNumbers={calendarShowWeekNumbers}
|
||||
showTimeInMonthView={calendarShowTimeInMonth}
|
||||
timeFormat={calendarTimeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
onSelectDate={handleSelectDate}
|
||||
onLongPressDate={openCreate}
|
||||
/>
|
||||
@@ -1084,6 +1086,7 @@ export default function CalendarScreen() {
|
||||
calendars={calendars}
|
||||
weekStartsOn={calendarFirstDayOfWeek}
|
||||
timeFormat={calendarTimeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onCreateAtTime={openCreate}
|
||||
@@ -1098,6 +1101,7 @@ export default function CalendarScreen() {
|
||||
calendars={calendars}
|
||||
weekStartsOn={calendarFirstDayOfWeek}
|
||||
timeFormat={calendarTimeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onCreateAtTime={openCreate}
|
||||
@@ -1118,6 +1122,7 @@ export default function CalendarScreen() {
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
timeFormat={calendarTimeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
/>
|
||||
)}
|
||||
@@ -1137,6 +1142,7 @@ export default function CalendarScreen() {
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
timeFormat={calendarTimeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
@@ -1261,6 +1267,7 @@ function DayEventList({
|
||||
eventsByDay,
|
||||
calendars,
|
||||
timeFormat,
|
||||
currentUserEmails,
|
||||
onSelectEvent,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
@@ -1269,6 +1276,7 @@ function DayEventList({
|
||||
eventsByDay: EventDayIndex;
|
||||
calendars: Calendar[];
|
||||
timeFormat?: TimeFormat;
|
||||
currentUserEmails?: string[];
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
@@ -1307,6 +1315,7 @@ function DayEventList({
|
||||
event={event}
|
||||
calendars={calendars}
|
||||
timeFormat={timeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
onPress={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user