desktop sync support

This commit is contained in:
Boof2015
2026-07-06 14:14:41 -04:00
parent 006e55422b
commit 2b19f51a49
26 changed files with 4183 additions and 208 deletions
+173
View File
@@ -0,0 +1,173 @@
// Read-only queue sheet for the Desktop Remote: the desktop's current +
// upcoming tracks, tap-to-play. Deliberately NOT QueueTray — that component is
// welded to the local RNTP queue store (drag-reorder, swipe-remove,
// multi-select), none of which applies to a remote snapshot. Uses an INLINE
// BottomSheet like QueueTray does — BottomSheetModal's portal does not work in
// this app's screen setups (see queue-tray-sheet gotcha).
import { useCallback, useEffect, useMemo } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import BottomSheet, {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
useBottomSheetScrollableCreator,
} from '@gorhom/bottom-sheet';
import { FlashList, type ListRenderItemInfo } from '@shopify/flash-list';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { formatDuration } from '@/lib/format';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import type { DesktopRemoteQueueItem } from '@/types/desktopRemote';
interface RemoteQueueSheetProps {
onClose: () => void;
}
export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) {
const queue = useDesktopRemoteStore((s) => s.queue);
const snapPoints = useMemo(() => ['58%', '100%'], []);
const renderFlashListScrollComponent = useBottomSheetScrollableCreator();
// The SSE stream keeps the queue fresh while connected; refresh once on open
// in case the stream fell back to snapshot polling (which has no queue).
useEffect(() => {
void useDesktopRemoteStore.getState().refreshQueue();
}, []);
const items = queue?.items ?? [];
const upcomingCount = items.filter((item) => !item.isCurrent).length;
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
pressBehavior="close"
opacity={0.58}
/>
),
[]
);
const playItem = useCallback(
(item: DesktopRemoteQueueItem) => {
if (item.isCurrent) return;
void useDesktopRemoteStore.getState().playQueueItem(item.queueId);
onClose();
},
[onClose]
);
const renderItem = useCallback(
({ item }: ListRenderItemInfo<DesktopRemoteQueueItem>) => (
<Pressable
style={({ pressed }) => [styles.row, pressed && !item.isCurrent && styles.rowPressed]}
onPress={() => playItem(item)}
disabled={item.isCurrent}
accessibilityRole="button"
accessibilityLabel={
item.isCurrent ? `Now playing: ${item.title}` : `Play ${item.title} on desktop`
}
>
<View style={styles.rowText}>
<Text
variant="body"
numberOfLines={1}
style={item.isCurrent ? styles.titleActive : undefined}
>
{item.title || 'Unknown title'}
</Text>
<Text variant="label" numberOfLines={1} color={colors.textTertiary}>
{item.artist || 'Unknown artist'}
</Text>
</View>
{item.isCurrent ? (
<Ionicons name="volume-high" size={18} color={colors.accent} />
) : item.durationSeconds !== null ? (
<Text variant="label" color={colors.textTertiary}>
{formatDuration(item.durationSeconds)}
</Text>
) : null}
</Pressable>
),
[playItem]
);
return (
<BottomSheet
index={0}
snapPoints={snapPoints}
enableDynamicSizing={false}
enablePanDownToClose
onClose={onClose}
backdropComponent={renderBackdrop}
backgroundStyle={styles.sheetBg}
handleIndicatorStyle={styles.handle}
>
<View style={styles.headerRow}>
<Text variant="heading">Desktop queue</Text>
<Text variant="label" color={colors.textTertiary}>
{upcomingCount === 1 ? '1 song up next' : `${upcomingCount} songs up next`}
</Text>
</View>
<FlashList
data={items}
keyExtractor={(item) => item.queueId}
renderScrollComponent={renderFlashListScrollComponent}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<View style={styles.empty}>
<Text variant="body" color={colors.textSecondary}>
The desktop queue is empty.
</Text>
</View>
}
/>
</BottomSheet>
);
}
const styles = StyleSheet.create({
sheetBg: {
backgroundColor: colors.bgSecondary,
borderRadius: radius.lg,
},
handle: {
backgroundColor: colors.textTertiary,
},
headerRow: {
flexDirection: 'row',
alignItems: 'baseline',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
},
listContent: {
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xl,
},
row: {
minHeight: 56,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
rowPressed: {
opacity: 0.6,
},
rowText: {
flex: 1,
minWidth: 0,
},
titleActive: {
color: colors.accent,
},
empty: {
paddingVertical: spacing.xl,
alignItems: 'center',
},
});
+347
View File
@@ -0,0 +1,347 @@
import { StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { formatRelativeTime } from '@/lib/format';
import {
buildSyncPlaylistEntryDiff,
syncPlaylistToSnapshot,
type SyncPlaylistEntryDiff,
} from '@/shared/sync/conflictPreview';
import type {
DesktopSyncConflictResolution,
DesktopSyncPlaylistConflict,
SyncPlaylistSnapshot,
} from '@/types/desktopSync';
function playlistKindLabel(snapshot: SyncPlaylistSnapshot): string {
return snapshot.kind === 'dynamic'
? 'Dynamic playlist'
: `${snapshot.trackCount} song${snapshot.trackCount === 1 ? '' : 's'}`;
}
function sideSummary(snapshot: SyncPlaylistSnapshot): string {
return `${playlistKindLabel(snapshot)} · edited ${formatRelativeTime(snapshot.updatedAt)}`;
}
function diffSummary(conflict: DesktopSyncPlaylistConflict): string {
const desktop = syncPlaylistToSnapshot(conflict.remote);
const phone = syncPlaylistToSnapshot(conflict.local);
if (desktop.kind !== 'normal' || phone.kind !== 'normal') {
return desktop.dynamicRules === phone.dynamicRules
? 'Names or dynamic playlist metadata differ.'
: 'Dynamic playlist rules differ.';
}
const diff = buildSyncPlaylistEntryDiff(desktop.entries, phone.entries);
const parts = [
diff.desktopOnlyCount > 0 ? `${diff.desktopOnlyCount} only on desktop` : null,
diff.phoneOnlyCount > 0 ? `${diff.phoneOnlyCount} only on phone` : null,
diff.movedCount > 0 ? `${diff.movedCount} in a different order` : null,
].filter((part): part is string => part !== null);
if (parts.length > 0) return parts.join(' · ');
if (desktop.name.trim() !== phone.name.trim()) return 'Playlist names differ.';
return 'Same songs; playlist metadata differs.';
}
function previewStatusLabel(
row: SyncPlaylistEntryDiff,
side: 'desktop' | 'phone',
resolution: DesktopSyncConflictResolution | null
): string | null {
if (!resolution) return null;
if (resolution === 'both') return 'Stays separate';
if (resolution === 'merge') return row.status === 'moved' ? 'Order chosen' : 'Added';
const keptSide = resolution;
if (side === keptSide) return row.status === 'moved' ? 'Order kept' : 'Kept';
return row.status === 'moved' ? 'Order changes' : 'Removed';
}
function moveStatusLabel(row: SyncPlaylistEntryDiff, side: 'desktop' | 'phone'): string {
if (row.status === 'moved') {
const from = side === 'desktop' ? row.desktopIndex : row.phoneIndex;
const to = side === 'desktop' ? row.phoneIndex : row.desktopIndex;
return from !== null && to !== null ? `${from + 1} to ${to + 1}` : 'Different order';
}
return '';
}
function TrackDiffRow({
row,
side,
previewResolution,
}: {
row: SyncPlaylistEntryDiff;
side: 'desktop' | 'phone';
previewResolution: DesktopSyncConflictResolution | null;
}) {
const subtitle = [row.artist, row.album].filter((part) => part.trim().length > 0).join(' · ');
const previewLabel = previewStatusLabel(row, side, previewResolution);
const moveLabel = row.status === 'moved' && !previewLabel ? moveStatusLabel(row, side) : null;
return (
<View style={[
styles.trackRow,
previewResolution === 'merge' && row.status !== 'moved' ? styles.trackRowAdded : null,
previewResolution === 'both' ? styles.trackRowSeparate : null,
previewResolution === 'desktop' && side === 'phone' ? styles.trackRowRemoved : null,
previewResolution === 'phone' && side === 'desktop' ? styles.trackRowRemoved : null,
]}>
<View style={styles.trackText}>
<Text variant="caption" numberOfLines={1}>
{row.title || 'Untitled track'}
</Text>
{subtitle ? (
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{subtitle}
</Text>
) : null}
</View>
{previewLabel || moveLabel ? (
<Text
variant="caption"
color={previewLabel ? colors.textSecondary : colors.textTertiary}
numberOfLines={1}
style={styles.trackBadge}
>
{previewLabel ?? moveLabel}
</Text>
) : null}
</View>
);
}
function SideTrackList({
side,
sideOnlyRows,
movedRows,
previewResolution,
maxRows,
}: {
side: 'desktop' | 'phone';
sideOnlyRows: SyncPlaylistEntryDiff[];
movedRows: SyncPlaylistEntryDiff[];
previewResolution: DesktopSyncConflictResolution | null;
maxRows: number;
}) {
const rows = [...sideOnlyRows, ...movedRows].slice(0, maxRows);
const hiddenCount = Math.max(0, sideOnlyRows.length + movedRows.length - rows.length);
const sideName = side === 'desktop' ? 'desktop' : 'phone';
if (rows.length === 0) {
return (
<Text variant="caption" color={colors.textTertiary}>
No songs only on {sideName}.
</Text>
);
}
return (
<View style={styles.trackRows}>
{sideOnlyRows.length > 0 ? (
<Text variant="caption" color={colors.textTertiary} style={styles.sectionLabel}>
Only on {sideName}
</Text>
) : null}
{rows.map((row, index) => {
const startsMovedSection = row.status === 'moved' && rows[index - 1]?.status !== 'moved';
return (
<View key={row.key} style={styles.trackGroup}>
{startsMovedSection ? (
<Text variant="caption" color={colors.textTertiary} style={styles.sectionLabel}>
Different order
</Text>
) : null}
<TrackDiffRow row={row} side={side} previewResolution={previewResolution} />
</View>
);
})}
{hiddenCount > 0 ? (
<Text variant="caption" color={colors.textTertiary}>
+{hiddenCount} more
</Text>
) : null}
</View>
);
}
export function SyncConflictDetails({
conflict,
desktopName,
maxRows = 4,
previewResolution = null,
}: {
conflict: DesktopSyncPlaylistConflict;
desktopName: string;
maxRows?: number;
previewResolution?: DesktopSyncConflictResolution | null;
}) {
const desktop = syncPlaylistToSnapshot(conflict.remote);
const phone = syncPlaylistToSnapshot(conflict.local);
const isNormal = desktop.kind === 'normal' && phone.kind === 'normal';
const diff = isNormal ? buildSyncPlaylistEntryDiff(desktop.entries, phone.entries) : null;
const desktopOnlyRows = diff?.rows.filter((row) => row.status === 'desktop-only') ?? [];
const phoneOnlyRows = diff?.rows.filter((row) => row.status === 'phone-only') ?? [];
const movedRows = diff?.rows.filter((row) => row.status === 'moved') ?? [];
const desktopDimmed = previewResolution === 'phone';
const phoneDimmed = previewResolution === 'desktop';
const desktopActive = previewResolution === 'desktop' || previewResolution === 'both' || previewResolution === 'merge';
const phoneActive = previewResolution === 'phone' || previewResolution === 'both' || previewResolution === 'merge';
return (
<View style={styles.container}>
<View style={styles.compareGrid}>
<View style={[
styles.sideCard,
desktopDimmed ? styles.sideCardDimmed : null,
desktopActive ? styles.sideCardActive : null,
]}>
<View style={styles.sideHead}>
<Ionicons name="desktop-outline" size={15} color={colors.textSecondary} />
<Text variant="caption" color={colors.textSecondary} numberOfLines={1} style={styles.sideTitle}>
{desktopName}
</Text>
</View>
<Text variant="caption" numberOfLines={1}>
{desktop.name}
</Text>
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{sideSummary(desktop)}
</Text>
{isNormal ? (
<SideTrackList
side="desktop"
sideOnlyRows={desktopOnlyRows}
movedRows={movedRows}
previewResolution={previewResolution}
maxRows={maxRows}
/>
) : (
<Text variant="caption" color={colors.textTertiary} numberOfLines={3}>
{desktop.dynamicRules ?? 'No rules'}
</Text>
)}
</View>
<View style={[
styles.sideCard,
phoneDimmed ? styles.sideCardDimmed : null,
phoneActive ? styles.sideCardActive : null,
]}>
<View style={styles.sideHead}>
<Ionicons name="phone-portrait-outline" size={15} color={colors.textSecondary} />
<Text variant="caption" color={colors.textSecondary} numberOfLines={1} style={styles.sideTitle}>
This phone
</Text>
</View>
<Text variant="caption" numberOfLines={1}>
{phone.name}
</Text>
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{sideSummary(phone)}
</Text>
{isNormal ? (
<SideTrackList
side="phone"
sideOnlyRows={phoneOnlyRows}
movedRows={movedRows}
previewResolution={previewResolution}
maxRows={maxRows}
/>
) : (
<Text variant="caption" color={colors.textTertiary} numberOfLines={3}>
{phone.dynamicRules ?? 'No rules'}
</Text>
)}
</View>
</View>
<View style={styles.summaryBlock}>
<Text variant="caption" color={colors.textSecondary}>
{diffSummary(conflict)}
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: spacing.sm,
},
compareGrid: {
flexDirection: 'row',
gap: spacing.sm,
},
sideCard: {
flex: 1,
minWidth: 0,
borderRadius: radius.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgTertiary,
padding: spacing.sm,
gap: spacing.xs,
},
sideCardActive: {
borderColor: colors.accent,
},
sideCardDimmed: {
opacity: 0.48,
},
sideHead: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
sideTitle: {
flex: 1,
minWidth: 0,
},
trackRows: {
gap: spacing.xs,
marginTop: spacing.xs,
},
trackGroup: {
gap: spacing.xs,
},
sectionLabel: {
textTransform: 'uppercase',
letterSpacing: 0.4,
},
trackRow: {
minHeight: 42,
borderRadius: radius.sm,
backgroundColor: colors.bgSecondary,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
trackRowAdded: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
},
trackRowSeparate: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
trackRowRemoved: {
opacity: 0.46,
},
trackText: {
flex: 1,
minWidth: 0,
},
trackBadge: {
maxWidth: 104,
},
summaryBlock: {
borderRadius: radius.sm,
backgroundColor: colors.bgTertiary,
padding: spacing.sm,
gap: spacing.xs,
},
});
+314
View File
@@ -0,0 +1,314 @@
// Root-mounted popup for desktop-sync conflicts: fires the moment a sync run
// detects NEW conflicts (auto or manual) instead of waiting for the user to
// wander into Settings. The once-per-session bookkeeping lives in
// desktopSyncStore (conflictPromptVisible) so this component is a pure
// derivation of store state — no set-state-in-effect (React Compiler rule).
// Suppressed while the user is already on the sync screen; if they leave it
// without resolving, the one pending reminder still shows.
import { useState } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router, usePathname } from 'expo-router';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { formatRelativeTime } from '@/lib/format';
import {
buildSyncConflictResolutionPreview,
buildSyncPlaylistEntryDiff,
syncPlaylistToSnapshot,
} from '@/shared/sync/conflictPreview';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import type {
DesktopSyncConflictResolution,
DesktopSyncPlaylistConflict,
SyncPlaylistSnapshot,
} from '@/types/desktopSync';
const RESOLUTION_LABELS: Record<DesktopSyncConflictResolution, string> = {
desktop: 'Use desktop version',
phone: 'Use phone version',
both: 'Keep both playlists',
merge: 'Combine songs',
};
function resolutionOptions(conflict: DesktopSyncPlaylistConflict): DesktopSyncConflictResolution[] {
return conflict.playlistKind === 'dynamic'
? ['desktop', 'phone', 'both']
: ['desktop', 'phone', 'both', 'merge'];
}
function sideSubtitle(snapshot: SyncPlaylistSnapshot): string {
const count = snapshot.kind === 'dynamic'
? 'Dynamic playlist'
: `${snapshot.trackCount} song${snapshot.trackCount === 1 ? '' : 's'}`;
return `${count} · edited ${formatRelativeTime(snapshot.updatedAt)}`;
}
function diffLine(desktop: SyncPlaylistSnapshot, phone: SyncPlaylistSnapshot): string {
if (desktop.kind !== 'normal' || phone.kind !== 'normal') {
return desktop.dynamicRules === phone.dynamicRules
? 'The playlist details do not match.'
: 'The playlist rules do not match.';
}
const diff = buildSyncPlaylistEntryDiff(desktop.entries, phone.entries);
const parts = [
diff.desktopOnlyCount > 0 ? `${diff.desktopOnlyCount} only on desktop` : null,
diff.phoneOnlyCount > 0 ? `${diff.phoneOnlyCount} only on phone` : null,
diff.movedCount > 0 ? `${diff.movedCount} in a different order` : null,
].filter((part): part is string => part !== null);
return parts.length > 0 ? parts.join(' · ') : 'The playlists have the same songs.';
}
export function SyncConflictPrompt() {
const conflicts = useDesktopSyncStore((s) => s.conflicts);
const status = useDesktopSyncStore((s) => s.status);
const promptVisible = useDesktopSyncStore((s) => s.conflictPromptVisible);
const dismissConflictPrompt = useDesktopSyncStore((s) => s.dismissConflictPrompt);
const resolveConflict = useDesktopSyncStore((s) => s.resolveConflict);
const pathname = usePathname();
const [choice, setChoice] = useState<{
syncUid: string;
resolution: DesktopSyncConflictResolution;
} | null>(null);
const visible = promptVisible && conflicts.length > 0 && pathname !== '/desktop-sync';
if (!visible) return null;
const count = conflicts.length;
const firstConflict = conflicts[0];
const busy = status === 'syncing';
const desktopSnapshot = syncPlaylistToSnapshot(firstConflict.remote);
const phoneSnapshot = syncPlaylistToSnapshot(firstConflict.local);
const options = resolutionOptions(firstConflict);
const selectedResolution = choice?.syncUid === firstConflict.syncUid && options.includes(choice.resolution)
? choice.resolution
: null;
const preview = selectedResolution
? buildSyncConflictResolutionPreview(selectedResolution, desktopSnapshot, phoneSnapshot)
: null;
const review = () => {
dismissConflictPrompt();
router.push('/desktop-sync' as never);
};
const confirm = () => {
if (!selectedResolution || busy) return;
dismissConflictPrompt();
void resolveConflict(firstConflict, selectedResolution);
};
return (
<Modal visible transparent animationType="fade" onRequestClose={dismissConflictPrompt}>
<View style={styles.backdrop}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={dismissConflictPrompt}
accessibilityLabel="Dismiss"
/>
<View style={styles.card}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
<View style={styles.header}>
<Ionicons name="git-compare-outline" size={22} color={colors.warning} />
<View style={styles.titleBlock}>
<Text variant="heading" style={styles.title}>
Sync conflict{count === 1 ? '' : 's'}
</Text>
{count > 1 ? (
<Text variant="caption" color={colors.textTertiary}>
1 of {count}
</Text>
) : null}
</View>
</View>
<Text variant="body" color={colors.textSecondary} style={styles.body}>
{firstConflict.localName} is different on desktop and this phone.
</Text>
<View style={styles.sideSummaryGrid}>
<View style={styles.sideSummaryCard}>
<Text variant="label" color={colors.textSecondary}>
Desktop
</Text>
<Text variant="caption" numberOfLines={1}>
{desktopSnapshot.name}
</Text>
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{sideSubtitle(desktopSnapshot)}
</Text>
</View>
<View style={styles.sideSummaryCard}>
<Text variant="label" color={colors.textSecondary}>
This phone
</Text>
<Text variant="caption" numberOfLines={1}>
{phoneSnapshot.name}
</Text>
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{sideSubtitle(phoneSnapshot)}
</Text>
</View>
</View>
<Text variant="caption" color={colors.textTertiary}>
{diffLine(desktopSnapshot, phoneSnapshot)}
</Text>
<View style={styles.choiceList}>
{options.map((resolution) => (
<Pressable
key={resolution}
style={[
styles.choiceRow,
selectedResolution === resolution ? styles.choiceRowSelected : null,
busy && styles.disabled,
]}
disabled={busy}
onPress={() => setChoice({ syncUid: firstConflict.syncUid, resolution })}
>
<Text variant="label">{RESOLUTION_LABELS[resolution]}</Text>
</Pressable>
))}
</View>
<View style={styles.previewBox}>
<Text variant="label">
{preview ? preview.title : 'Choose what should happen'}
</Text>
<Text variant="caption" color={colors.textSecondary}>
{preview
? preview.detail
: 'Nothing changes until you confirm.'}
</Text>
</View>
{count > 1 ? (
<Pressable onPress={review} style={styles.reviewLink}>
<Text variant="caption" color={colors.accent}>
Review all {count} conflicts
</Text>
</Pressable>
) : null}
</ScrollView>
<View style={styles.actions}>
<Pressable style={styles.secondaryButton} onPress={dismissConflictPrompt}>
<Text variant="body" color={colors.textSecondary}>
Not now
</Text>
</Pressable>
<Pressable
style={[styles.primaryButton, (!selectedResolution || busy) && styles.disabled]}
disabled={!selectedResolution || busy}
onPress={confirm}
>
<Text variant="body" color={colors.accentTextStrong}>
Confirm
</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
},
card: {
width: '100%',
maxWidth: 400,
maxHeight: '88%',
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgSecondary,
padding: spacing.lg,
gap: spacing.sm,
},
scrollContent: {
gap: spacing.md,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
titleBlock: {
flex: 1,
},
title: {
flex: 1,
},
body: {
lineHeight: 21,
},
sideSummaryGrid: {
flexDirection: 'row',
gap: spacing.sm,
},
sideSummaryCard: {
flex: 1,
minWidth: 0,
borderRadius: radius.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgTertiary,
padding: spacing.sm,
gap: 2,
},
choiceList: {
gap: spacing.sm,
},
choiceRow: {
minHeight: 42,
borderRadius: radius.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgTertiary,
paddingHorizontal: spacing.md,
alignItems: 'flex-start',
justifyContent: 'center',
},
choiceRowSelected: {
borderColor: colors.accent,
backgroundColor: colors.glassHighlight,
},
previewBox: {
borderRadius: radius.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgTertiary,
padding: spacing.md,
gap: spacing.xs,
},
reviewLink: {
alignSelf: 'flex-start',
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.xs,
},
secondaryButton: {
minHeight: 44,
borderRadius: radius.sm,
paddingHorizontal: spacing.lg,
alignItems: 'center',
justifyContent: 'center',
},
primaryButton: {
minHeight: 44,
borderRadius: radius.sm,
backgroundColor: colors.accent,
paddingHorizontal: spacing.lg,
alignItems: 'center',
justifyContent: 'center',
},
disabled: {
opacity: 0.5,
},
});