mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 16:07:32 +00:00
feat: add a toast host, prompt dialog, haptics helper and reduce-motion aware animations
This commit is contained in:
+63
-13
@@ -1,9 +1,39 @@
|
||||
{
|
||||
"advanced_search": {
|
||||
"after": "After",
|
||||
"before": "Before",
|
||||
"no_attachment": "No attachment",
|
||||
"not_starred": "Not starred"
|
||||
},
|
||||
"calendar": {
|
||||
"event_modal": {
|
||||
"participant_placeholder": "Add participant by email"
|
||||
},
|
||||
"invitation": {
|
||||
"accept": "Yes",
|
||||
"add_error": "Could not add the event",
|
||||
"add_to_calendar": "Add to calendar",
|
||||
"added": "Added to calendar",
|
||||
"already_in_calendar": "Already in your calendar",
|
||||
"cancelled": "This event was cancelled",
|
||||
"decline": "No",
|
||||
"join_video": "Join video call",
|
||||
"no_writable_calendar": "No writable calendar available",
|
||||
"organizer": "Organizer",
|
||||
"reading": "Reading invitation…",
|
||||
"reply": "A participant replied to this invitation",
|
||||
"response_error": "Could not send your response",
|
||||
"response_sent": "Response sent",
|
||||
"tentative": "Maybe",
|
||||
"title": "Calendar invitation",
|
||||
"trust_verified": "Sender verified"
|
||||
},
|
||||
"management": {
|
||||
"clear_confirm": "Remove all",
|
||||
"clear_description": "Every event in this calendar will be deleted. Events that also belong to another calendar are only unlinked.",
|
||||
"clear_title": "Remove all events?",
|
||||
"delete_description": "The calendar and all of its events will be deleted. This cannot be undone.",
|
||||
"delete_title": "Delete calendar?",
|
||||
"description_placeholder": "Optional",
|
||||
"edit_calendar": "Edit calendar",
|
||||
"new_calendar": "New calendar"
|
||||
@@ -22,10 +52,13 @@
|
||||
"common": {
|
||||
"add": "Add",
|
||||
"back": "Back",
|
||||
"confirm": "Confirm",
|
||||
"create": "Create",
|
||||
"dismiss": "Dismiss",
|
||||
"done": "Done",
|
||||
"remove": "Remove"
|
||||
"remove": "Remove",
|
||||
"retry": "Retry",
|
||||
"undo": "Undo"
|
||||
},
|
||||
"context_menu": {
|
||||
"color_tag": "Tag",
|
||||
@@ -48,27 +81,41 @@
|
||||
"schedule_unsupported_title": "Scheduling unavailable",
|
||||
"scheduled_body": "Your message will be sent at {time}.",
|
||||
"scheduled_title": "Scheduled",
|
||||
"send_now": "Send now",
|
||||
"send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.",
|
||||
"send_timeout_title": "No answer from the server",
|
||||
"undo_send_label": "Sending in {seconds}s…",
|
||||
"validation": {
|
||||
"subject_required": "Add a subject"
|
||||
}
|
||||
},
|
||||
"email_list": {
|
||||
"error": "Error",
|
||||
"marked_as_spam": "Marked as spam",
|
||||
"marked_not_spam": "Marked as not spam",
|
||||
"move_same_account": "Messages can only be moved within the same account",
|
||||
"moved_to_trash": "Email moved to Trash",
|
||||
"no_archive_folder": "Could not find an Archive folder on the server.",
|
||||
"no_junk_folder": "Could not find a Spam/Junk folder on the server.",
|
||||
"no_trash_folder": "Could not find a Trash folder on the server. Please check your mailbox configuration.",
|
||||
"undo_failed": "Undo failed",
|
||||
"marked_as_spam_count": "{count, plural, one {# email} other {# emails}} marked as spam",
|
||||
"marked_not_spam_count": "{count, plural, one {# email} other {# emails}} marked as not spam",
|
||||
"emails_archived_count": "{count, plural, one {# email} other {# emails}} archived",
|
||||
"emails_moved_to": "{count, plural, one {# email} other {# emails}} moved to {mailbox}",
|
||||
"moved_to_trash_count": "{count, plural, one {# email} other {# emails}} moved to Trash"
|
||||
"error": "Error",
|
||||
"marked_as_spam": "Marked as spam",
|
||||
"marked_as_spam_count": "{count, plural, one {# email} other {# emails}} marked as spam",
|
||||
"marked_not_spam": "Marked as not spam",
|
||||
"marked_not_spam_count": "{count, plural, one {# email} other {# emails}} marked as not spam",
|
||||
"move_same_account": "Messages can only be moved within the same account",
|
||||
"moved_to_trash": "Email moved to Trash",
|
||||
"moved_to_trash_count": "{count, plural, one {# email} other {# emails}} moved to Trash",
|
||||
"no_archive_folder": "Could not find an Archive folder on the server.",
|
||||
"no_junk_folder": "Could not find a Spam/Junk folder on the server.",
|
||||
"no_mailboxes": "No mailboxes found",
|
||||
"no_mailboxes_hint": "Check that your JMAP account has mail capability.",
|
||||
"no_trash_folder": "Could not find a Trash folder on the server. Please check your mailbox configuration.",
|
||||
"offline_nothing_cached": "No connection. Showing nothing because no mail has been cached yet.",
|
||||
"open_draft_failed": "Could not open the draft.",
|
||||
"recent_searches": "Recent searches",
|
||||
"scope_all_folders": "All folders",
|
||||
"scope_this_folder": "This folder",
|
||||
"search_placeholder": "Search mail...",
|
||||
"showing_cached": "Showing cached mail",
|
||||
"swipe_move": "Move",
|
||||
"swipe_spam": "Spam",
|
||||
"total": "total",
|
||||
"undo_failed": "Undo failed"
|
||||
},
|
||||
"email_viewer": {
|
||||
"color_tag": {
|
||||
@@ -231,6 +278,9 @@
|
||||
"not_implemented": "Not implemented",
|
||||
"unavailable": "Unavailable"
|
||||
},
|
||||
"experimental": {
|
||||
"title": "Experimental Feature"
|
||||
},
|
||||
"keywords": {
|
||||
"reset_defaults": "Reset to Defaults"
|
||||
},
|
||||
|
||||
Generated
+29
@@ -18,9 +18,11 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-clipboard": "^8.0.7",
|
||||
"expo-crypto": "~15.0.9",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-haptics": "^15.0.8",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
@@ -31,6 +33,7 @@
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"postal-mime": "^3.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
@@ -5234,6 +5237,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-clipboard": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
|
||||
"integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "18.0.13",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||
@@ -5279,6 +5293,15 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-haptics": {
|
||||
"version": "15.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz",
|
||||
"integrity": "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-loader": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
|
||||
@@ -8235,6 +8258,12 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postal-mime": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-3.0.0.tgz",
|
||||
"integrity": "sha512-Z4a9ar2Bv3YpK3IXag+Yda30k7bMZfpRuUGyqtHnZ2pjHG8Bl62EhZIk4n1dzv00gfzP9g+94e9kd8+XmjVWLA==",
|
||||
"license": "MIT-0"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.49",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
||||
|
||||
@@ -26,9 +26,11 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-clipboard": "^8.0.7",
|
||||
"expo-crypto": "~15.0.9",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-haptics": "^15.0.8",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
@@ -39,6 +41,7 @@
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"postal-mime": "^3.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, Modal } from 'react-native';
|
||||
import { View, Text, StyleSheet, Modal, TextInput, type TextInputProps } from 'react-native';
|
||||
import { AlertTriangle } from 'lucide-react-native';
|
||||
import Button from './Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
|
||||
interface DialogInput {
|
||||
value: string;
|
||||
onChangeText: (value: string) => void;
|
||||
placeholder?: string;
|
||||
// Extra TextInput props (keyboardType, autoCapitalize, secureTextEntry…).
|
||||
props?: Omit<TextInputProps, 'value' | 'onChangeText' | 'placeholder' | 'style'>;
|
||||
}
|
||||
|
||||
interface DialogProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
message?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
// Turns the confirm dialog into a prompt (webmail prompt-dialog.tsx).
|
||||
input?: DialogInput;
|
||||
confirmDisabled?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -30,13 +42,18 @@ export default function Dialog({
|
||||
title,
|
||||
message,
|
||||
variant = 'default',
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmText,
|
||||
cancelText,
|
||||
input,
|
||||
confirmDisabled,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DialogProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const confirmLabel = confirmText ?? t('common.confirm', 'Confirm');
|
||||
const cancelLabel = cancelText ?? t('common.cancel', 'Cancel');
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
||||
<View style={styles.backdrop}>
|
||||
@@ -47,15 +64,33 @@ export default function Dialog({
|
||||
<AlertTriangle size={20} color={c.error} />
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.message}>{message}</Text>
|
||||
<Text style={styles.title} accessibilityRole="header">{title}</Text>
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
{input ? (
|
||||
<TextInput
|
||||
value={input.value}
|
||||
onChangeText={input.onChangeText}
|
||||
placeholder={input.placeholder}
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoFocus
|
||||
onSubmitEditing={confirmDisabled ? undefined : onConfirm}
|
||||
accessibilityLabel={input.placeholder ?? title}
|
||||
style={styles.input}
|
||||
{...input.props}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.footer}>
|
||||
<Button variant="outline" size="sm" onPress={onCancel}>
|
||||
{cancelText}
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={variant === 'destructive' ? 'destructive' : 'default'} size="sm" onPress={onConfirm}>
|
||||
{confirmText}
|
||||
<Button
|
||||
variant={variant === 'destructive' ? 'destructive' : 'default'}
|
||||
size="sm"
|
||||
onPress={onConfirm}
|
||||
disabled={confirmDisabled}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -107,6 +142,17 @@ function makeStyles(c: ThemePalette) {
|
||||
color: c.mutedForeground, // text-muted-foreground
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
backgroundColor: c.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 10,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function RadioGroup({ options, value, onChange, style }: RadioGro
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<View style={[styles.container, style]} accessibilityRole="radiogroup">
|
||||
{options.map((opt) => {
|
||||
const selected = opt.value === value;
|
||||
return (
|
||||
@@ -34,6 +34,9 @@ export default function RadioGroup({ options, value, onChange, style }: RadioGro
|
||||
key={opt.value}
|
||||
style={[styles.option, selected ? styles.optionSelected : styles.optionUnselected]}
|
||||
onPress={() => onChange(opt.value)}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected, checked: selected }}
|
||||
accessibilityLabel={opt.label}
|
||||
>
|
||||
<Text style={[styles.optionText, selected ? styles.textSelected : styles.textUnselected]}>
|
||||
{opt.label}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Animated, Easing } 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 { useToastStore, type Toast } from '../stores/toast-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
|
||||
/**
|
||||
* Renders the toast queue above the tab bar. One host is mounted in App.tsx;
|
||||
* the email undo snackbar keeps its own component because it is bound to
|
||||
* the email store's pending-undo entry.
|
||||
*/
|
||||
export function ToastHost(): React.ReactElement | null {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
const insets = useSafeAreaInsets();
|
||||
if (toasts.length === 0) return null;
|
||||
return (
|
||||
<View pointerEvents="box-none" style={[styles.wrap, { bottom: insets.bottom + 64 }]}>
|
||||
{toasts.map((t) => <ToastCard key={t.id} toast={t} />)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastCard({ toast }: { toast: Toast }) {
|
||||
const c = useColors();
|
||||
const cardStyles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const removeToast = useToastStore((s) => s.removeToast);
|
||||
const duration = useAnimDuration(200);
|
||||
const opacity = React.useRef(new Animated.Value(0)).current;
|
||||
const translateY = React.useRef(new Animated.Value(24)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, { toValue: 1, duration, useNativeDriver: true }),
|
||||
Animated.timing(translateY, { toValue: 0, duration, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
]).start();
|
||||
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]);
|
||||
|
||||
const accent = toast.type === 'success'
|
||||
? c.success
|
||||
: toast.type === 'error'
|
||||
? c.error
|
||||
: toast.type === 'warning'
|
||||
? c.warning
|
||||
: c.info;
|
||||
const Icon = toast.type === 'success'
|
||||
? CheckCircle2
|
||||
: toast.type === 'error'
|
||||
? XCircle
|
||||
: toast.type === 'warning'
|
||||
? AlertTriangle
|
||||
: Info;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[cardStyles.card, { borderLeftColor: accent, opacity, transform: [{ translateY }] }]}
|
||||
accessibilityRole="alert"
|
||||
accessibilityLiveRegion="polite"
|
||||
>
|
||||
<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]}
|
||||
>
|
||||
<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>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: {
|
||||
position: 'absolute',
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
gap: spacing.sm,
|
||||
zIndex: 60,
|
||||
},
|
||||
});
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
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,
|
||||
backgroundColor: c.popover,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { FlaskConical, Lock, ChevronDown, Check } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import ToggleSwitchComponent from '../ToggleSwitch';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
|
||||
/**
|
||||
* Mirrors webmail settings-section.tsx:
|
||||
@@ -39,13 +40,14 @@ export function SettingsSection({
|
||||
}: SettingsSectionProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
{experimental && (
|
||||
<View style={styles.experimentalBanner}>
|
||||
<FlaskConical size={20} color="#fbbf24" />
|
||||
<FlaskConical size={20} color={c.warning} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.experimentalTitle}>Experimental Feature</Text>
|
||||
<Text style={styles.experimentalTitle}>{t('settings.experimental.title', 'Experimental Feature')}</Text>
|
||||
{experimentalDescription && (
|
||||
<Text style={styles.experimentalDesc}>{experimentalDescription}</Text>
|
||||
)}
|
||||
@@ -119,13 +121,16 @@ export function RadioGroup({ value, onChange, options, style }: RadioGroupProps)
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={[styles.radioGroup, style]}>
|
||||
<View style={[styles.radioGroup, style]} accessibilityRole="radiogroup">
|
||||
{options.map((opt) => {
|
||||
const selected = opt.value === value;
|
||||
return (
|
||||
<Pressable
|
||||
key={opt.value}
|
||||
onPress={() => onChange(opt.value)}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected, checked: selected }}
|
||||
accessibilityLabel={opt.label}
|
||||
style={[
|
||||
styles.radioOption,
|
||||
selected ? styles.radioSelected : styles.radioUnselected,
|
||||
@@ -159,7 +164,13 @@ export function Select({ value, onChange, options, style }: SelectProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable style={[styles.select, style]} onPress={() => setOpen(true)}>
|
||||
<Pressable
|
||||
style={[styles.select, style]}
|
||||
onPress={() => setOpen(true)}
|
||||
accessibilityRole="combobox"
|
||||
accessibilityLabel={current?.label ?? ''}
|
||||
accessibilityState={{ expanded: open }}
|
||||
>
|
||||
<Text style={styles.selectText}>{current?.label ?? ''}</Text>
|
||||
<ChevronDown size={14} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
@@ -177,6 +188,8 @@ export function Select({ value, onChange, options, style }: SelectProps) {
|
||||
return (
|
||||
<Pressable
|
||||
key={opt.value}
|
||||
accessibilityRole="menuitem"
|
||||
accessibilityState={{ selected }}
|
||||
style={({ pressed }) => [
|
||||
styles.selectItem,
|
||||
pressed && styles.selectItemPressed,
|
||||
@@ -220,18 +233,18 @@ function makeStyles(c: ThemePalette) {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(252, 211, 77, 0.4)',
|
||||
backgroundColor: 'rgba(120, 53, 15, 0.25)',
|
||||
borderColor: c.warning,
|
||||
backgroundColor: c.warningBg,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
experimentalTitle: {
|
||||
...typography.bodyMedium,
|
||||
color: '#fcd34d',
|
||||
color: c.text,
|
||||
},
|
||||
experimentalDesc: {
|
||||
...typography.caption,
|
||||
color: '#fde68a',
|
||||
color: c.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
settingItem: {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Platform } from 'react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
// Tactile feedback helpers. Every call is fire-and-forget and swallows
|
||||
// errors: haptics are a nicety, never a dependency. Callers outside this
|
||||
// area (SwipeableRow, destructive confirms) should use these rather than
|
||||
// importing expo-haptics directly so the "off" switch stays in one place.
|
||||
|
||||
export type HapticKind = 'selection' | 'light' | 'medium' | 'heavy' | 'success' | 'warning' | 'error';
|
||||
|
||||
let enabled = true;
|
||||
|
||||
/** Global kill switch (e.g. when the user disables animations). */
|
||||
export function setHapticsEnabled(value: boolean): void {
|
||||
enabled = value;
|
||||
}
|
||||
|
||||
export function haptic(kind: HapticKind = 'light'): void {
|
||||
if (!enabled || Platform.OS === 'web') return;
|
||||
let promise: Promise<void>;
|
||||
try {
|
||||
switch (kind) {
|
||||
case 'selection':
|
||||
promise = Haptics.selectionAsync();
|
||||
break;
|
||||
case 'medium':
|
||||
promise = Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
break;
|
||||
case 'heavy':
|
||||
promise = Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
|
||||
break;
|
||||
case 'success':
|
||||
promise = Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
break;
|
||||
case 'warning':
|
||||
promise = Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||
break;
|
||||
case 'error':
|
||||
promise = Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
break;
|
||||
case 'light':
|
||||
default:
|
||||
promise = Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
break;
|
||||
}
|
||||
void promise.catch(() => undefined);
|
||||
} catch {
|
||||
// native module missing (e.g. web / tests)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Lightweight port of the webmail's stores/toast-store.ts: a queue of typed
|
||||
// toasts with an optional action, rendered by components/ToastHost. Use it
|
||||
// for post-action acknowledgements and non-blocking errors instead of a
|
||||
// modal Alert.
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
export interface ToastAction {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
title: string;
|
||||
message?: string;
|
||||
action?: ToastAction;
|
||||
// ms; errors default to 10 s like the webmail, everything else to 5 s.
|
||||
duration: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, 'id' | 'createdAt' | 'duration'> & { duration?: number }) => string;
|
||||
removeToast: (id: string) => void;
|
||||
clearToasts: () => void;
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
|
||||
addToast: (toast) => {
|
||||
const id = `toast-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
||||
const entry: Toast = {
|
||||
...toast,
|
||||
id,
|
||||
createdAt: Date.now(),
|
||||
duration: toast.duration ?? (toast.type === 'error' ? 10_000 : 5_000),
|
||||
};
|
||||
// Keep the queue short: a burst of errors should not stack up forever.
|
||||
set((state) => ({ toasts: [...state.toasts.slice(-2), entry] }));
|
||||
return id;
|
||||
},
|
||||
|
||||
removeToast: (id) => {
|
||||
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
|
||||
},
|
||||
|
||||
clearToasts: () => set({ toasts: [] }),
|
||||
}));
|
||||
|
||||
interface ToastOptions {
|
||||
message?: string;
|
||||
action?: ToastAction;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
function show(type: ToastType, title: string, options?: string | ToastOptions): string {
|
||||
const opts = typeof options === 'string' ? { message: options } : options;
|
||||
return useToastStore.getState().addToast({
|
||||
type,
|
||||
title,
|
||||
message: opts?.message,
|
||||
action: opts?.action,
|
||||
duration: opts?.duration,
|
||||
});
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (title: string, options?: string | ToastOptions) => show('success', title, options),
|
||||
error: (title: string, options?: string | ToastOptions) => show('error', title, options),
|
||||
info: (title: string, options?: string | ToastOptions) => show('info', title, options),
|
||||
warning: (title: string, options?: string | ToastOptions) => show('warning', title, options),
|
||||
dismiss: (id: string) => useToastStore.getState().removeToast(id),
|
||||
};
|
||||
+33
-2
@@ -3,7 +3,8 @@
|
||||
// scaled / preference-driven variants so screens can react to settings
|
||||
// without a relaunch.
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AccessibilityInfo } from 'react-native';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { typography as baseTypography, spacing as baseSpacing } from './tokens';
|
||||
|
||||
@@ -84,10 +85,40 @@ export function useDensity() {
|
||||
);
|
||||
}
|
||||
|
||||
// OS-level "reduce motion" (Android: Remove animations / iOS: Reduce Motion).
|
||||
// Read once and kept fresh via the change event; defaults to false until the
|
||||
// first answer arrives.
|
||||
let reduceMotionCache: boolean | null = null;
|
||||
function useReduceMotion(): boolean {
|
||||
const [reduce, setReduce] = useState<boolean>(reduceMotionCache ?? false);
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const info = AccessibilityInfo as typeof AccessibilityInfo | undefined;
|
||||
if (!info?.isReduceMotionEnabled) return undefined;
|
||||
void info.isReduceMotionEnabled().then((value) => {
|
||||
reduceMotionCache = value;
|
||||
if (mounted) setReduce(value);
|
||||
}).catch(() => undefined);
|
||||
const sub = info.addEventListener?.('reduceMotionChanged', (value: boolean) => {
|
||||
reduceMotionCache = value;
|
||||
if (mounted) setReduce(value);
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
sub?.remove?.();
|
||||
};
|
||||
}, []);
|
||||
return reduce;
|
||||
}
|
||||
|
||||
// Returns whether the user has opted into in-app animations. When false,
|
||||
// callers should pass duration=0 to `Animated.timing` so transitions snap.
|
||||
// Honours the OS reduce-motion preference as well (webmail:
|
||||
// prefers-reduced-motion).
|
||||
export function useShouldAnimate() {
|
||||
return useSettingsStore((s) => s.animationsEnabled);
|
||||
const enabled = useSettingsStore((s) => s.animationsEnabled);
|
||||
const reduceMotion = useReduceMotion();
|
||||
return enabled && !reduceMotion;
|
||||
}
|
||||
|
||||
// Convenience: returns `requested` when animations are on, otherwise 0.
|
||||
|
||||
Reference in New Issue
Block a user