import { StyleSheet, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Text } from '@/components/Text'; import { radius, spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; 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 styles = useStyles(); const colors = useColors(); 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 ( {row.title || 'Untitled track'} {subtitle ? ( {subtitle} ) : null} {previewLabel || moveLabel ? ( {previewLabel ?? moveLabel} ) : null} ); } function SideTrackList({ side, sideOnlyRows, movedRows, previewResolution, maxRows, }: { side: 'desktop' | 'phone'; sideOnlyRows: SyncPlaylistEntryDiff[]; movedRows: SyncPlaylistEntryDiff[]; previewResolution: DesktopSyncConflictResolution | null; maxRows: number; }) { const styles = useStyles(); const colors = useColors(); 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 ( No songs only on {sideName}. ); } return ( {sideOnlyRows.length > 0 ? ( Only on {sideName} ) : null} {rows.map((row, index) => { const startsMovedSection = row.status === 'moved' && rows[index - 1]?.status !== 'moved'; return ( {startsMovedSection ? ( Different order ) : null} ); })} {hiddenCount > 0 ? ( +{hiddenCount} more ) : null} ); } export function SyncConflictDetails({ conflict, desktopName, maxRows = 4, previewResolution = null, }: { conflict: DesktopSyncPlaylistConflict; desktopName: string; maxRows?: number; previewResolution?: DesktopSyncConflictResolution | null; }) { const styles = useStyles(); const colors = useColors(); 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 ( {desktopName} {desktop.name} {sideSummary(desktop)} {isNormal ? ( ) : ( {desktop.dynamicRules ?? 'No rules'} )} This phone {phone.name} {sideSummary(phone)} {isNormal ? ( ) : ( {phone.dynamicRules ?? 'No rules'} )} {diffSummary(conflict)} ); } const useStyles = createThemedStyles((colors) => ({ 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, }, }));