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)}
+ >
+
+
+ );
+}
+
+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