CityTrack / User /src /screens /issues /IssueDetailScreen.tsx
0xarchit's picture
User app beta v1 complete
71638d4
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
Image,
Dimensions,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { Card } from '../../components/ui/Card';
import { Button } from '../../components/ui/Button';
import { issueService } from '../../services/issueService';
import { colors, spacing, typography, borderRadius } from '../../theme';
import { Issue } from '../../types';
const { width } = Dimensions.get('window');
type IssueDetailRouteParams = {
IssueDetail: {
issueId: string;
};
};
const priorityConfig: Record<number, { color: string; label: string }> = {
1: { color: colors.priority.critical, label: 'CRITICAL' },
2: { color: colors.priority.high, label: 'HIGH' },
3: { color: colors.priority.medium, label: 'MEDIUM' },
4: { color: colors.priority.low, label: 'LOW' },
};
const stateConfig: Record<string, { color: string; label: string; iconName: keyof typeof Ionicons.glyphMap }> = {
reported: { color: colors.status.info, label: 'Reported', iconName: 'document-text' },
validated: { color: colors.accent.purple, label: 'Validated', iconName: 'checkmark-circle' },
assigned: { color: colors.accent.cyan, label: 'Assigned', iconName: 'person' },
in_progress: { color: colors.status.warning, label: 'In Progress', iconName: 'construct' },
resolved: { color: colors.status.success, label: 'Resolved', iconName: 'checkmark-done-circle' },
closed: { color: colors.text.tertiary, label: 'Closed', iconName: 'archive' },
rejected: { color: colors.status.error, label: 'Rejected', iconName: 'close-circle' },
};
export function IssueDetailScreen() {
const route = useRoute<RouteProp<IssueDetailRouteParams, 'IssueDetail'>>();
const navigation = useNavigation();
const { issueId } = route.params;
const [issue, setIssue] = useState<Issue | null>(null);
const [loading, setLoading] = useState(true);
const [activeImage, setActiveImage] = useState(0);
const [showAnnotated, setShowAnnotated] = useState(true);
useEffect(() => {
fetchIssue();
}, [issueId]);
const fetchIssue = async () => {
try {
const data = await issueService.getIssue(issueId);
setIssue(data);
} catch (error) {
console.error('Failed to fetch issue:', error);
} finally {
setLoading(false);
}
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
);
}
if (!issue) {
return (
<View style={styles.errorContainer}>
<Ionicons name="sad-outline" size={64} color={colors.text.tertiary} />
<Text style={styles.errorText}>Issue not found</Text>
<Button title="Go Back" onPress={() => navigation.goBack()} />
</View>
);
}
const priorityInfo = issue.priority ? priorityConfig[issue.priority] : null;
const stateInfo = stateConfig[issue.state] || stateConfig.reported;
const displayImages = showAnnotated && issue.annotated_urls.length > 0
? issue.annotated_urls
: issue.image_urls;
return (
<LinearGradient
colors={[colors.background.primary, colors.background.secondary]}
style={styles.container}
>
<ScrollView showsVerticalScrollIndicator={false}>
<View style={styles.imageContainer}>
{displayImages.length > 0 ? (
<Image
source={{ uri: displayImages[activeImage] }}
style={styles.mainImage}
resizeMode="cover"
/>
) : (
<View style={[styles.mainImage, styles.placeholderImage]}>
<Ionicons name="camera" size={48} color={colors.text.tertiary} />
</View>
)}
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.7)']}
style={styles.imageGradient}
/>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<Ionicons name="arrow-back" size={24} color={colors.text.primary} />
</TouchableOpacity>
{issue.annotated_urls.length > 0 && (
<View style={styles.imageToggle}>
<TouchableOpacity
style={[styles.toggleButton, !showAnnotated && styles.toggleActive]}
onPress={() => setShowAnnotated(false)}
>
<Text style={styles.toggleText}>Original</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.toggleButton, showAnnotated && styles.toggleActive]}
onPress={() => setShowAnnotated(true)}
>
<Text style={styles.toggleText}>AI View</Text>
</TouchableOpacity>
</View>
)}
</View>
<View style={styles.content}>
<View style={styles.badges}>
{priorityInfo ? (
<View style={[styles.badge, { backgroundColor: priorityInfo.color }]}>
<Text style={styles.badgeText}>{priorityInfo.label}</Text>
</View>
) : null}
<View style={[styles.badge, styles.stateBadge, { borderColor: stateInfo.color }]}>
<Ionicons name={stateInfo.iconName} size={14} color={stateInfo.color} />
<Text style={[styles.badgeText, { color: stateInfo.color }]}>
{stateInfo.label}
</Text>
</View>
</View>
{issue.category ? (
<Text style={styles.category}>{issue.category}</Text>
) : null}
{issue.confidence !== undefined && issue.confidence !== null ? (
<Card style={styles.confidenceCard} variant="glass">
<View style={styles.confidenceRow}>
<Text style={styles.confidenceLabel}>AI Confidence</Text>
<Text style={styles.confidenceValue}>
{(issue.confidence * 100).toFixed(0)}%
</Text>
</View>
<View style={styles.confidenceBar}>
<View
style={[
styles.confidenceFill,
{ width: `${issue.confidence * 100}%` }
]}
/>
</View>
</Card>
) : null}
{issue.description ? (
<Card style={styles.descriptionCard} variant="glass">
<Text style={styles.sectionTitle}>Description</Text>
<Text style={styles.description}>{issue.description}</Text>
</Card>
) : null}
<Card style={styles.detailsCard} variant="glass">
<Text style={styles.sectionTitle}>Details</Text>
<View style={styles.detailRow}>
<View style={styles.detailLabelContainer}>
<Ionicons name="location" size={16} color={colors.text.secondary} />
<Text style={styles.detailLabel}>Location</Text>
</View>
<Text style={styles.detailValue}>
{issue.latitude.toFixed(6)}, {issue.longitude.toFixed(6)}
</Text>
</View>
<View style={styles.detailRow}>
<View style={styles.detailLabelContainer}>
<Ionicons name="time" size={16} color={colors.text.secondary} />
<Text style={styles.detailLabel}>Reported</Text>
</View>
<Text style={styles.detailValue}>{formatDate(issue.created_at)}</Text>
</View>
{issue.is_duplicate ? (
<View style={styles.detailRow}>
<View style={styles.detailLabelContainer}>
<Ionicons name="link" size={16} color={colors.status.warning} />
<Text style={styles.detailLabel}>Status</Text>
</View>
<Text style={[styles.detailValue, { color: colors.status.warning }]}>
Linked to existing report
</Text>
</View>
) : null}
{issue.geo_status ? (
<View style={styles.detailRow}>
<View style={styles.detailLabelContainer}>
<Ionicons name="analytics" size={16} color={colors.text.secondary} />
<Text style={styles.detailLabel}>Geo Status</Text>
</View>
<Text style={styles.detailValue}>{issue.geo_status}</Text>
</View>
) : null}
</Card>
<View style={styles.timeline}>
<Text style={styles.sectionTitle}>Status Timeline</Text>
<View style={styles.timelineItem}>
<View style={[styles.timelineDot, { backgroundColor: stateInfo.color }]} />
<View style={styles.timelineContent}>
<Text style={styles.timelineTitle}>{stateInfo.label}</Text>
<Text style={styles.timelineDate}>{formatDate(issue.updated_at)}</Text>
</View>
</View>
</View>
</View>
</ScrollView>
</LinearGradient>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
backgroundColor: colors.background.primary,
justifyContent: 'center',
alignItems: 'center',
},
errorContainer: {
flex: 1,
backgroundColor: colors.background.primary,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
errorText: {
...typography.h3,
color: colors.text.primary,
marginBottom: spacing.xl,
marginTop: spacing.lg,
},
imageContainer: {
width: width,
height: 300,
position: 'relative',
},
mainImage: {
width: '100%',
height: '100%',
},
placeholderImage: {
backgroundColor: colors.background.tertiary,
alignItems: 'center',
justifyContent: 'center',
},
imageGradient: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 100,
},
backButton: {
position: 'absolute',
top: spacing.xxl,
left: spacing.lg,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255,255,255,0.2)', // Light glass
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
alignItems: 'center',
justifyContent: 'center',
},
imageToggle: {
position: 'absolute',
bottom: spacing.lg,
right: spacing.lg,
flexDirection: 'row',
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: borderRadius.full,
padding: 4,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
},
toggleButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
},
toggleActive: {
backgroundColor: colors.primary.main,
},
toggleText: {
color: colors.text.primary,
fontSize: 12,
fontWeight: '600',
},
content: {
padding: spacing.lg,
},
badges: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
marginBottom: spacing.md,
},
badge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
gap: spacing.xs,
},
stateBadge: {
backgroundColor: 'rgba(255,255,255,0.1)',
borderWidth: 1,
},
badgeText: {
color: colors.text.primary,
fontSize: 12,
fontWeight: '700',
letterSpacing: 0.5,
},
category: {
fontSize: 28,
fontWeight: '800',
color: colors.text.primary,
marginBottom: spacing.lg,
letterSpacing: -0.5,
},
confidenceCard: {
marginBottom: spacing.md,
backgroundColor: 'rgba(255,255,255,0.05)',
},
confidenceRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.sm,
},
confidenceLabel: {
fontSize: 14,
color: colors.text.secondary,
fontWeight: '500',
},
confidenceValue: {
fontSize: 24,
fontWeight: '700',
color: colors.secondary.main,
fontFamily: 'monospace', // Assuming Fira Code availability or fallback
},
confidenceBar: {
height: 6,
backgroundColor: 'rgba(255,255,255,0.1)',
borderRadius: 3,
overflow: 'hidden',
},
confidenceFill: {
height: '100%',
backgroundColor: colors.secondary.main,
borderRadius: 3,
},
descriptionCard: {
marginBottom: spacing.md,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.text.primary,
marginBottom: spacing.md,
},
description: {
fontSize: 15,
color: colors.text.secondary,
lineHeight: 24,
},
detailsCard: {
marginBottom: spacing.lg,
},
detailRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.05)',
},
detailLabelContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
detailLabel: {
fontSize: 14,
color: colors.text.secondary,
fontWeight: '500',
},
detailValue: {
fontSize: 15,
color: colors.text.primary,
flex: 1,
textAlign: 'right',
marginLeft: spacing.md,
fontWeight: '600',
},
timeline: {
marginBottom: spacing.xl,
},
timelineItem: {
flexDirection: 'row',
alignItems: 'center',
},
timelineDot: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: spacing.md,
shadowColor: colors.primary.main, // Glow effect placeholder
shadowOpacity: 0.5,
shadowRadius: 4,
},
timelineContent: {
flex: 1,
},
timelineTitle: {
fontSize: 16,
color: colors.text.primary,
fontWeight: '700',
},
timelineDate: {
fontSize: 13,
color: colors.text.tertiary,
marginTop: 2,
},
});