diff --git a/mobile/src/screens/AnalyticsScreen.tsx b/mobile/src/screens/AnalyticsScreen.tsx index 3eaced1..692fe7b 100644 --- a/mobile/src/screens/AnalyticsScreen.tsx +++ b/mobile/src/screens/AnalyticsScreen.tsx @@ -1,32 +1,22 @@ import React, { useCallback, useState } from 'react'; import { - View, Text, ScrollView, StyleSheet + View, Text, ScrollView, StyleSheet, Platform, } from 'react-native'; +import Svg, { Rect, Line, Text as SvgText } from 'react-native-svg'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useFocusEffect } from '@react-navigation/native'; -import { colors, spacing, radius, font, shadow } from '../lib/theme'; +import { ios } from '../lib/theme'; import { listRecords, type ScanRecord } from '../lib/storage'; -function StatRow({ label, value, accent = colors.text }: { label: string; value: string; accent?: string }) { - return ( - - {label} - {value} - - ); -} +const CHART_H = 140; +const CHART_PAD_X = 16; +const CHART_PAD_TOP = 14; +const CHART_PAD_BOTTOM = 28; +const BAR_W = 22; -function WeightBar({ value, max, label }: { value: number; max: number; label: string }) { - const pct = max > 0 ? (value / max) * 100 : 0; - return ( - - {label} - - - - {value.toFixed(0)} kg - - ); +function prettyBreed(b: string): string { + if (b === 'default') return 'Unspecified'; + return b.charAt(0).toUpperCase() + b.slice(1); } export default function AnalyticsScreen() { @@ -37,9 +27,10 @@ export default function AnalyticsScreen() { listRecords().then(setRecords); }, [])); - const weights = records.map(r => r.result.estimated_weight_kg); const totalScans = records.length; const realAnimals = records.filter(r => r.detection.is_real_animal).length; + + const weights = records.map(r => r.result.estimated_weight_kg); const avgWeight = weights.length ? weights.reduce((a, b) => a + b, 0) / weights.length : 0; const maxWeight = weights.length ? Math.max(...weights) : 0; const minWeight = weights.length ? Math.min(...weights) : 0; @@ -57,6 +48,7 @@ export default function AnalyticsScreen() { avg: ws.reduce((a, b) => a + b, 0) / ws.length, count: ws.length, })).sort((a, b) => b.avg - a.avg); + const breedMax = breedStats.length ? Math.max(...breedStats.map(b => b.avg)) : 0; const last7: number[] = Array(7).fill(0); const now = Date.now(); @@ -64,138 +56,354 @@ export default function AnalyticsScreen() { const daysAgo = Math.floor((now - r.scannedAt) / 86400000); if (daysAgo < 7) last7[6 - daysAgo]++; }); + const last7Max = Math.max(...last7, 1); + const last7Total = last7.reduce((a, b) => a + b, 0); return ( - - Análises - {totalScans} scan{totalScans !== 1 ? 's' : ''} no total + + + Analytics + + {totalScans === 0 + ? 'No data yet' + : `${totalScans} scan${totalScans !== 1 ? 's' : ''} · all time`} + + {totalScans === 0 ? ( - Nenhum dado para exibir ainda. - Faça scans para ver as análises aqui. + No data to show + + Record a few scans to see weight trends, breed distribution and activity here. + ) : ( <> - {/* KPIs */} - - Resumo - - - - - - + {/* Hero — Mean live weight */} + + + + Mean live weight + + {avgWeight.toFixed(0)} + kg + + + Range {minWeight.toFixed(0)}–{maxWeight.toFixed(0)} kg + {' · '} + Avg confidence {avgConf.toFixed(0)}% + + + + + + {totalScans} + scans + + + {realAnimals} + real animals + + + {Object.keys(breedMap).length} + breeds + + - {/* Activity last 7 days */} - - Scans — Últimos 7 dias - - - {last7.map((count, i) => { - const maxVal = Math.max(...last7, 1); - const h = (count / maxVal) * 80; - const day = new Date(now - (6 - i) * 86400000).toLocaleDateString('pt-BR', { weekday: 'short' }); + {/* Activity chart */} + Activity + + + Last 7 days + {last7Total} scans + + + + + {/* Weight by breed */} + {breedStats.length > 0 && ( + <> + Mean weight by breed + + {breedStats.map((b, i, arr) => { + const pct = breedMax > 0 ? (b.avg / breedMax) * 100 : 0; return ( - - {count > 0 ? count : ''} - - + + + + + {prettyBreed(b.breed)} + · {b.count} + + {b.avg.toFixed(0)} kg + + + + - {day.slice(0, 3)} + {i < arr.length - 1 && } ); })} - - + + )} - {/* By breed */} - {breedStats.length > 0 && ( - - Por Raça - - {breedStats.map(b => ( - - ))} + {/* Summary */} + Summary + + {[ + { k: 'Max weight', v: `${maxWeight.toFixed(1)} kg` }, + { k: 'Min weight', v: `${minWeight.toFixed(1)} kg` }, + { k: 'Mean confidence', v: `${avgConf.toFixed(1)}%` }, + { k: 'Real animals', v: `${realAnimals} / ${totalScans}` }, + ].map(({ k, v }, i, arr) => ( + + + {k} + {v} + + {i < arr.length - 1 && } - - )} + ))} + + + All figures computed from locally stored scans. Trend over longer windows requires backend sync. + )} ); } +function ActivityChart({ values, max, now }: { values: number[]; max: number; now: number }) { + const chartWidthFallback = 320; // recomputed via onLayout in real usage; static fallback for SSR / first paint + const [width, setWidth] = useState(chartWidthFallback); + + const innerW = width - CHART_PAD_X * 2; + const innerH = CHART_H - CHART_PAD_TOP - CHART_PAD_BOTTOM; + const slot = innerW / values.length; + const barOffset = (slot - BAR_W) / 2; + + return ( + setWidth(e.nativeEvent.layout.width)} + > + + {/* baseline */} + + + {values.map((count, i) => { + const h = max > 0 ? (count / max) * innerH : 0; + const x = CHART_PAD_X + slot * i + barOffset; + const y = CHART_H - CHART_PAD_BOTTOM - h; + const dayDate = new Date(now - (6 - i) * 86400000); + const dayLabel = dayDate.toLocaleDateString('en-GB', { weekday: 'short' }).slice(0, 1); + + return ( + + {/* bar */} + 0 ? ios.accent : '#E5E5EA'} + /> + {/* count label above bar */} + {count > 0 && ( + + {String(count)} + + )} + {/* day letter under bar */} + + {dayLabel} + + + ); + })} + + + ); +} + +const displayFont = Platform.select({ ios: 'System', android: undefined, default: undefined }); + const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: colors.background }, - header: { - paddingHorizontal: spacing.md, paddingBottom: spacing.md, - backgroundColor: colors.surface, - borderBottomWidth: 1, borderBottomColor: colors.border, - }, - title: { color: colors.text, fontSize: font.lg, fontWeight: '700' }, - subtitle: { color: colors.textMuted, fontSize: font.sm, marginTop: 2 }, - section: { paddingHorizontal: spacing.md, marginTop: spacing.md }, - sectionTitle: { color: colors.text, fontSize: font.md, fontWeight: '700', marginBottom: spacing.sm }, - kpiCard: { - backgroundColor: colors.surface, - borderRadius: radius.lg, - borderWidth: 1, borderColor: colors.border, + scroll: { flex: 1, backgroundColor: ios.systemGroupedBackground }, + + largeTitle: { paddingHorizontal: 20, paddingBottom: 6 }, + title: { + fontFamily: displayFont, + fontSize: 34, fontWeight: '700', + letterSpacing: -0.95, color: ios.label, lineHeight: 36, + }, + subtitle: { + fontSize: 13, color: ios.tertiaryLabel, + marginTop: 4, letterSpacing: -0.05, + }, + + // Sections + group: { paddingHorizontal: 16 }, + sectionHeader: { + marginTop: 28, marginBottom: 8, + paddingHorizontal: 32, + fontSize: 13, fontWeight: '400', + color: ios.secondaryLabel, + textTransform: 'uppercase', letterSpacing: 0.5, + }, + sectionFooter: { + marginTop: 8, + paddingHorizontal: 32, + fontSize: 13, lineHeight: 18, + color: ios.secondaryLabel, + letterSpacing: -0.05, + }, + card: { + marginHorizontal: 16, + backgroundColor: ios.secondarySystemGroupedBackground, + borderRadius: 12, overflow: 'hidden', - ...shadow.sm, - }, - statRow: { - flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - paddingHorizontal: spacing.md, paddingVertical: 12, - borderBottomWidth: 1, borderBottomColor: colors.border, - }, - statLabel: { color: colors.textMuted, fontSize: font.sm }, - statValue: { fontSize: font.md, fontWeight: '700' }, - barItem: { - flexDirection: 'row', alignItems: 'center', gap: spacing.sm, - paddingHorizontal: spacing.md, paddingVertical: 10, - borderBottomWidth: 1, borderBottomColor: colors.border, - }, - barLabel: { color: colors.textMuted, fontSize: font.xs, width: 90 }, - barTrack: { - flex: 1, height: 6, backgroundColor: 'rgba(255,255,255,0.06)', + }, + + // Hero + hero: { padding: 22, gap: 8 }, + eyebrow: { + fontSize: 13, fontWeight: '600', + color: ios.accent, letterSpacing: -0.05, + }, + valueRow: { flexDirection: 'row', alignItems: 'baseline', gap: 6 }, + value: { + fontFamily: displayFont, + fontSize: 56, fontWeight: '700', + lineHeight: 56, letterSpacing: -2.2, color: ios.label, + }, + valueUnit: { + fontFamily: displayFont, + fontSize: 22, fontWeight: '500', + color: ios.secondaryLabel, letterSpacing: -0.2, + }, + heroMeta: { + fontSize: 13, color: ios.secondaryLabel, + marginTop: 4, letterSpacing: -0.05, + }, + heroDivider: { + height: StyleSheet.hairlineWidth, + backgroundColor: ios.separator, + }, + + // Tiles inside hero card + tiles: { flexDirection: 'row' }, + tile: { + flex: 1, alignItems: 'center', + paddingVertical: 14, gap: 4, + }, + tileMid: { + borderLeftWidth: StyleSheet.hairlineWidth, + borderRightWidth: StyleSheet.hairlineWidth, + borderColor: ios.separator, + }, + tileValue: { + fontFamily: displayFont, + fontSize: 24, fontWeight: '700', + letterSpacing: -0.6, color: ios.label, + }, + tileLabel: { + fontSize: 11, color: ios.secondaryLabel, + letterSpacing: -0.05, + }, + + // Activity chart + activityHeader: { + flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', + paddingHorizontal: 16, paddingTop: 14, paddingBottom: 6, + }, + activityTitle: { + fontSize: 15, fontWeight: '600', + color: ios.label, letterSpacing: -0.2, + }, + activityTotal: { + fontSize: 13, color: ios.secondaryLabel, + letterSpacing: -0.05, + }, + + // Breed bars + breedRow: { paddingHorizontal: 16, paddingVertical: 12, gap: 6 }, + breedTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline' }, + breedLabel: { + fontSize: 15, color: ios.label, letterSpacing: -0.2, + }, + breedCount: { + fontSize: 13, color: ios.tertiaryLabel, + fontWeight: '400', + }, + breedValue: { + fontFamily: displayFont, + fontSize: 15, fontWeight: '600', + color: ios.label, letterSpacing: -0.2, + }, + breedTrack: { + height: 6, backgroundColor: '#E5E5EA', borderRadius: 3, overflow: 'hidden', }, - barFill: { height: 6, backgroundColor: colors.primary, borderRadius: 3 }, - barValue: { color: colors.text, fontSize: font.xs, width: 52, textAlign: 'right' }, - chartRow: { - flexDirection: 'row', justifyContent: 'space-around', - alignItems: 'flex-end', paddingHorizontal: spacing.md, - paddingTop: spacing.md, paddingBottom: spacing.sm, - height: 130, - }, - chartCol: { alignItems: 'center', gap: 4, flex: 1 }, - chartCount: { color: colors.textMuted, fontSize: font.xs, height: 16 }, - chartBarTrack: { - width: 20, height: 80, - backgroundColor: 'rgba(255,255,255,0.06)', borderRadius: 4, - justifyContent: 'flex-end', overflow: 'hidden', - }, - chartBar: { width: 20, backgroundColor: colors.primary, borderRadius: 4 }, - chartDay: { color: colors.textDim, fontSize: font.xs }, + breedFill: { + height: 6, backgroundColor: ios.accent, borderRadius: 3, + }, + + // Summary rows + row: { + minHeight: 44, + paddingHorizontal: 16, paddingVertical: 11, + flexDirection: 'row', alignItems: 'center', gap: 12, + }, + rowDivider: { + marginLeft: 16, + height: StyleSheet.hairlineWidth, + backgroundColor: ios.separator, + }, + rowKey: { + flex: 1, + fontSize: 17, color: ios.label, letterSpacing: -0.3, + }, + rowVal: { + fontFamily: displayFont, + fontSize: 17, fontWeight: '500', + color: ios.secondaryLabel, letterSpacing: -0.3, + }, + + // Empty empty: { - alignItems: 'center', gap: spacing.sm, - paddingVertical: spacing.xxl, - paddingHorizontal: spacing.xl, + alignItems: 'center', gap: 8, + paddingTop: 60, paddingBottom: 40, paddingHorizontal: 40, + }, + emptyTitle: { + fontSize: 17, fontWeight: '600', + color: ios.label, letterSpacing: -0.3, + }, + emptySub: { + fontSize: 13, color: ios.secondaryLabel, + textAlign: 'center', letterSpacing: -0.05, lineHeight: 18, }, - emptyText: { color: colors.textMuted, fontSize: font.lg, fontWeight: '600' }, - emptySubText: { color: colors.textDim, fontSize: font.sm, textAlign: 'center' }, }); diff --git a/mobile/src/screens/HerdScreen.tsx b/mobile/src/screens/HerdScreen.tsx index 2305e90..e474c72 100644 --- a/mobile/src/screens/HerdScreen.tsx +++ b/mobile/src/screens/HerdScreen.tsx @@ -1,19 +1,48 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import { - View, Text, FlatList, StyleSheet, - TouchableOpacity, Alert, TextInput, + View, Text, ScrollView, StyleSheet, + TouchableOpacity, Alert, TextInput, Platform, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { colors, spacing, radius, font, shadow } from '../lib/theme'; +import { ios } from '../lib/theme'; import { listRecords, deleteRecord, type ScanRecord } from '../lib/storage'; -import StatusBadge from '../components/StatusBadge'; import type { RootStackParamList } from '../navigation/types'; type Nav = NativeStackNavigationProp; +type Section = { title: string; data: ScanRecord[] }; + +function startOfDay(d: Date) { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x; +} + +function groupRecords(records: ScanRecord[]): Section[] { + const today = startOfDay(new Date()).getTime(); + const weekAgo = today - 6 * 24 * 60 * 60 * 1000; + + const buckets: { today: ScanRecord[]; week: ScanRecord[]; earlier: ScanRecord[] } = { + today: [], week: [], earlier: [], + }; + + for (const r of records) { + const t = new Date(r.scannedAt).getTime(); + if (t >= today) buckets.today.push(r); + else if (t >= weekAgo) buckets.week.push(r); + else buckets.earlier.push(r); + } + + const sections: Section[] = []; + if (buckets.today.length) sections.push({ title: 'Today', data: buckets.today }); + if (buckets.week.length) sections.push({ title: 'This week', data: buckets.week }); + if (buckets.earlier.length) sections.push({ title: 'Earlier', data: buckets.earlier }); + return sections; +} + export default function HerdScreen() { const insets = useSafeAreaInsets(); const nav = useNavigation