Compare commits

...
7 Commits
14 changed files with 104 additions and 53 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.77
0.1.78
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.77",
"version": "0.1.78",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.77",
"version": "0.1.78",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.77",
"version": "0.1.78",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+15 -19
View File
@@ -7,7 +7,6 @@ import {
TextInput,
Pressable,
StyleSheet,
Switch,
Platform,
Alert as NativeAlert,
} from 'react-native';
@@ -70,6 +69,7 @@ import { ParticipantInput } from './ParticipantInput';
import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { RecurrenceEditor } from './RecurrenceEditor';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import ToggleSwitch from '../ToggleSwitch';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
@@ -459,7 +459,7 @@ export function EventModal({
value={title}
onChangeText={setTitle}
placeholder={t('calendar.form.title', 'Title')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
style={styles.titleInput}
/>
@@ -515,12 +515,10 @@ export function EventModal({
<Section icon={<Clock size={16} color={c.textMuted} />} label={t('calendar.form.time', 'Time')}>
<View style={styles.allDayRow}>
<Text style={styles.fieldText}>{t('calendar.form.all_day_event', 'All-day event')}</Text>
<Switch
<ToggleSwitch
value={allDay}
onValueChange={setAllDay}
accessibilityLabel={t('calendar.form.all_day_event', 'All-day event')}
thumbColor={allDay ? c.primary : c.textMuted}
trackColor={{ false: c.surface, true: c.primaryBg }}
/>
</View>
@@ -673,7 +671,7 @@ export function EventModal({
keyboardType="number-pad"
style={styles.customAmountInput}
placeholder="30"
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
/>
<View style={styles.customUnits}>
{(['minutes', 'hours', 'days', 'weeks'] as ReminderUnit[]).map((unit) => (
@@ -708,7 +706,7 @@ export function EventModal({
value={location}
onChangeText={setLocation}
placeholder={t('calendar.form.location_placeholder', 'Place or address')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
style={styles.fieldButton}
/>
</Section>
@@ -718,7 +716,7 @@ export function EventModal({
value={videoUrl}
onChangeText={setVideoUrl}
placeholder="https://meet.example.com/…"
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
@@ -734,7 +732,7 @@ export function EventModal({
value={description}
onChangeText={setDescription}
placeholder={t('calendar.form.description_placeholder', 'Notes, agenda…')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
multiline
style={styles.descriptionInput}
/>
@@ -756,12 +754,10 @@ export function EventModal({
<Text style={[styles.fieldText, { flex: 1 }]}>
{t('calendar.participants.send_invitations', 'Send invitations to participants')}
</Text>
<Switch
<ToggleSwitch
value={sendInvitations}
onValueChange={setSendInvitations}
accessibilityLabel={t('calendar.participants.send_invitations', 'Send invitations to participants')}
thumbColor={sendInvitations ? c.primary : c.textMuted}
trackColor={{ false: c.surface, true: c.primaryBg }}
/>
</View>
)}
@@ -913,7 +909,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
justifyContent: 'center',
},
fieldText: { ...typography.body, color: c.text },
@@ -930,7 +926,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
},
reminderRemove: { padding: 2 },
customReminderRow: {
@@ -939,7 +935,7 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
},
customAmountInput: {
...typography.body,
@@ -975,7 +971,7 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
backgroundColor: c.popover,
overflow: 'hidden',
},
popoverRow: {
@@ -997,7 +993,7 @@ function makeStyles(c: ThemePalette) {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
borderWidth: 1,
borderColor: c.border,
},
@@ -1009,7 +1005,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
},
dateLabel: { ...typography.body, color: c.textSecondary, flex: 1 },
datePickers: { flexDirection: 'row', gap: spacing.sm },
@@ -1030,7 +1026,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
color: c.text,
...typography.body,
textAlignVertical: 'top',
+2 -2
View File
@@ -530,7 +530,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
},
addBtn: {
width: 38,
@@ -552,7 +552,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
textAlignVertical: 'top',
},
editorRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
+1 -1
View File
@@ -136,6 +136,6 @@ function makeStyles(c: ThemePalette) {
info: { flex: 1, minWidth: 0 },
name: { ...typography.bodyMedium, color: c.text },
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 1 },
tertiary: { ...typography.small, color: c.textSecondary, marginTop: 1 },
tertiary: { ...typography.caption, color: c.mutedForeground, marginTop: 1 },
});
}
+35 -4
View File
@@ -7,6 +7,7 @@ import {
Modal,
ScrollView,
ViewStyle,
type LayoutChangeEvent,
} from 'react-native';
import { FlaskConical, Lock, ChevronDown, ChevronRight, Check, ExternalLink, type AppIcon } from '../icons';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -68,6 +69,9 @@ export function SettingsSection({
);
}
/** Narrowest label column before a row's control moves under the label. */
const STACK_BELOW = 140;
// The label of the SettingItem a control sits in. A switch has no text of
// its own, so without it a screen reader only announces "switch, off".
const SettingLabelContext = React.createContext<string | undefined>(undefined);
@@ -95,10 +99,27 @@ export function SettingItem({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const highlight = useSearchHighlight(label);
const rowStyle = [styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked, highlight.highlighted && styles.searchHighlight];
// The control sits on the right. When that leaves the label column narrower
// than STACK_BELOW (a long translation, two buttons), the control moves
// under the label, as the webmail stacks it on a phone. A new row width
// (rotation, split view) measures again.
const [stacked, setStacked] = useState(false);
const rowWidth = React.useRef(0);
const onRowLayout = (e: LayoutChangeEvent) => {
highlight.onLayout(e);
const width = e.nativeEvent.layout.width;
if (Math.abs(width - rowWidth.current) > 1) {
rowWidth.current = width;
setStacked(false);
}
};
const onContentLayout = (e: LayoutChangeEvent) => {
if (!stacked && children && e.nativeEvent.layout.width < STACK_BELOW) setStacked(true);
};
const rowStyle = [styles.settingItem, stacked && styles.settingItemStacked, !noBorder && styles.settingItemBorder, locked && styles.locked, highlight.highlighted && styles.searchHighlight];
const body = (
<>
<View style={styles.settingContent}>
<View style={[styles.settingContent, stacked && styles.settingContentStacked]} onLayout={onContentLayout}>
<View style={styles.settingLabelRow}>
<Text style={styles.settingLabel}>{label}</Text>
{locked && <Lock size={12} color={c.mutedForeground} />}
@@ -119,7 +140,7 @@ export function SettingItem({
return (
<Pressable
ref={highlight.ref}
onLayout={highlight.onLayout}
onLayout={onRowLayout}
onPress={onPress}
accessibilityRole={link === 'external' ? 'link' : 'button'}
style={({ pressed }) => [...rowStyle, pressed && styles.settingItemPressed]}
@@ -129,7 +150,7 @@ export function SettingItem({
);
}
return (
<View ref={highlight.ref} onLayout={highlight.onLayout} style={rowStyle}>
<View ref={highlight.ref} onLayout={onRowLayout} style={rowStyle}>
{body}
</View>
);
@@ -366,6 +387,16 @@ function makeStyles(c: ThemePalette) {
settingContent: {
flex: 1,
},
// The control under the label instead of beside it (see SettingItem).
settingItemStacked: {
flexDirection: 'column',
alignItems: 'flex-start',
gap: spacing.sm,
},
settingContentStacked: {
flex: 0,
alignSelf: 'stretch',
},
settingLabelRow: {
flexDirection: 'row',
alignItems: 'center',
+15
View File
@@ -7,6 +7,7 @@
import { I18nManager } from 'react-native';
import { getLocales } from 'expo-localization';
import { formatMessage, type MessageParams } from './format';
import { setDeviceLocales } from '../lib/intl-locale';
// English is the fallback for every lookup, so it is loaded eagerly. The other
// catalogs are loaded on first use; see CATALOG_LOADERS below.
@@ -212,6 +213,20 @@ export function detectDeviceLocale(): LocaleCode {
return 'en';
}
/**
* Hand the device locales to the date formatters (lib/intl-locale), so an
* en-AU phone formats dates as en-AU while reading the English catalog.
*/
export function registerDeviceLocales(): void {
try {
setDeviceLocales(getLocales().map((l) => ({ appLocale: resolveLocaleTag(l), languageTag: l.languageTag })));
} catch {
setDeviceLocales([]);
}
}
registerDeviceLocales();
function lookup(dict: Dictionary, key: string): string | undefined {
let current: unknown = dict;
for (const part of key.split('.')) {
+3 -2
View File
@@ -7,13 +7,14 @@ vi.mock('expo-localization', () => ({
getCalendars: () => [{ timeZone: 'UTC', firstWeekday: 1 }],
}));
import { intlLocale, resetIntlLocaleCache } from '../intl-locale';
import { intlLocale } from '../intl-locale';
import { registerDeviceLocales } from '../../i18n';
import { formatCalendarDate, formatListDate } from '../date-format';
import { formatHeaderDate } from '../email-date';
function setDevice(...tags: string[]) {
device.locales = tags.map((t) => ({ languageTag: t, languageCode: t.split('-')[0] }));
resetIntlLocaleCache();
registerDeviceLocales();
}
describe('intlLocale', () => {
+18 -15
View File
@@ -3,11 +3,25 @@
// order: en-AU writes 15/10/2026, en-US 10/15/2026. intlLocale() hands the
// date formatters the device's full tag when its language matches the app
// language, so an Australian or British phone gets its own dates (#75).
import { getLocales } from 'expo-localization';
import { resolveLocaleTag } from '../i18n';
//
// This module stays free of react-native and expo because the device sync
// engine reaches it through quote-header (see device-sync purity test). The
// i18n module reads the device locales and registers them here on load.
export interface DeviceLocale {
/** The app language this device locale selects, from resolveLocaleTag. */
appLocale: string | null;
languageTag: string;
}
let deviceLocales: DeviceLocale[] = [];
const cache = new Map<string, string>();
export function setDeviceLocales(locales: DeviceLocale[]): void {
deviceLocales = locales;
cache.clear();
}
// Android 14 appends regional preferences such as `-u-fw-mon-mu-celsius`.
// The app has its own time-format and week-start settings, so keep only the
// language, script and region.
@@ -25,14 +39,8 @@ function isUsable(tag: string): boolean {
}
function deviceTagFor(locale: string): string | null {
let devices: ReturnType<typeof getLocales>;
try {
devices = getLocales();
} catch {
return null;
}
for (const l of devices) {
if (!l.languageTag || resolveLocaleTag(l) !== locale) continue;
for (const l of deviceLocales) {
if (!l.languageTag || l.appLocale !== locale) continue;
const tag = baseTag(l.languageTag);
// A tag without a region adds nothing over the language subtag.
if (tag.includes('-') && isUsable(tag)) return tag;
@@ -52,8 +60,3 @@ export function intlLocale(locale: string | null | undefined): string {
cache.set(lang, resolved);
return resolved;
}
/** Test hook: forget the resolved tags so a changed device locale is seen. */
export function resetIntlLocaleCache(): void {
cache.clear();
}
+3 -2
View File
@@ -1,7 +1,7 @@
import React from 'react';
import {
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
Keyboard, Platform, ActivityIndicator, Alert, Modal, Switch, useWindowDimensions,
Keyboard, Platform, ActivityIndicator, Alert, Modal, useWindowDimensions,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { usePreventRemove } from '@react-navigation/native';
@@ -79,6 +79,7 @@ import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
import type { EmailAddress, Identity } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import ToggleSwitch from '../components/ToggleSwitch';
type Props = NativeStackScreenProps<RootStackParamList, 'Compose'>;
@@ -2861,7 +2862,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
)}
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>{t('email_composer.from_override.toggle_off', 'Override')}</Text>
<Switch
<ToggleSwitch
value={overrideEnabled}
onValueChange={setOverrideEnabled}
accessibilityLabel={t('email_composer.from_override.toggle_off', 'Override')}
+3 -2
View File
@@ -1982,7 +1982,7 @@ function makeStyles(c: ThemePalette) {
sectionBody: { gap: spacing.sm, paddingLeft: spacing.lg + spacing.xs },
field: { gap: 4 },
fieldLabel: { ...typography.caption, color: c.textMuted },
fieldLabel: { ...typography.caption, color: c.mutedForeground },
row2: { flexDirection: 'row', gap: spacing.sm },
input: {
@@ -1990,7 +1990,8 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
// The shared Input: transparent, bordered (webmail contact form).
backgroundColor: 'transparent',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
...typography.body,
+1 -1
View File
@@ -758,7 +758,7 @@ function makeStyles(c: ThemePalette) {
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
sectionHeaderText: { ...typography.small, color: c.primary },
sectionHeaderText: { ...typography.captionMedium, color: c.mutedForeground },
separator: { height: 1, backgroundColor: c.borderLight, marginLeft: 68 },
+4 -1
View File
@@ -416,7 +416,9 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
<Text style={styles.headerTitle} numberOfLines={1} accessibilityRole="header">{tabLabel}</Text>
</View>
<ScrollView ref={paneScrollRef} style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
{/* Keyed per page: React would otherwise reuse the list's ScrollView
and open the page at the list's scroll offset. */}
<ScrollView key={`page-${selectedTab}`} ref={paneScrollRef} style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
<View ref={paneContentRef}>
<SearchHighlightContext.Provider value={searchHighlight}>
{scopeBanner}
@@ -458,6 +460,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
</View>
<ScrollView
key="list"
style={styles.scrollArea}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"