Files
astra-mobile/src/components/sync/SyncConflictPrompt.tsx
T
2026-07-12 12:16:52 -04:00

320 lines
11 KiB
TypeScript

// 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 { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
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 styles = useStyles();
const ripple = useRipple();
const colors = useColors();
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 android_ripple={ripple.bounded}
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 android_ripple={ripple.bounded} onPress={review} style={styles.reviewLink}>
<Text variant="caption" color={colors.accent}>
Review all {count} conflicts
</Text>
</Pressable>
) : null}
</ScrollView>
<View style={styles.actions}>
<Pressable android_ripple={ripple.bounded} style={styles.secondaryButton} onPress={dismissConflictPrompt}>
<Text variant="body" color={colors.textSecondary}>
Not now
</Text>
</Pressable>
<Pressable android_ripple={ripple.bounded}
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 useStyles = createThemedStyles((colors) => ({
backdrop: {
flex: 1,
backgroundColor: colors.backdrop,
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,
},
}));