Sincronismy on system

This commit is contained in:
Rayan Konecny 2026-05-03 02:47:00 -03:00
parent 2712fd6fa9
commit 0dabfcc484
10 changed files with 1676 additions and 297 deletions

View file

@ -0,0 +1,36 @@
-- ============================================================
-- TopTran — Script de criação das tabelas no PostgreSQL
-- ============================================================
CREATE TABLE IF NOT EXISTS empresas (
id TEXT PRIMARY KEY,
nome TEXT NOT NULL,
custo_por_km NUMERIC(10,2) NOT NULL,
observacoes TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS usuarios (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
token TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS corridas (
id TEXT PRIMARY KEY,
usuario_id TEXT NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,
empresa TEXT NOT NULL,
km NUMERIC(10,2) NOT NULL,
custo_por_km NUMERIC(10,2) NOT NULL,
total NUMERIC(10,2) NOT NULL,
data TEXT NOT NULL,
sincronizado SMALLINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Índices para performance
CREATE INDEX IF NOT EXISTS idx_corridas_usuario_id ON corridas(usuario_id);
CREATE INDEX IF NOT EXISTS idx_corridas_sincronizado ON corridas(sincronizado);
CREATE INDEX IF NOT EXISTS idx_usuarios_email ON usuarios(email);

View file

@ -0,0 +1,190 @@
import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
import { router } from "expo-router";
import React from "react";
import {
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
type CadastroCard = {
icon: string;
label: string;
description: string;
onPress: () => void;
highlight?: boolean;
};
export default function CadastrosPage() {
const cards: CadastroCard[] = [
{
icon: "🏢",
label: "Empresas",
description: "Gerencie empresas e custos por km",
onPress: () => router.push("/empresas"),
highlight: true,
},
{
icon: "👤",
label: "Motoristas",
description: "Cadastro de motoristas",
onPress: () => {},
},
{
icon: "🚛",
label: "Veículos",
description: "Cadastro de veículos",
onPress: () => {},
},
{
icon: "📍",
label: "Rotas",
description: "Rotas e destinos frequentes",
onPress: () => {},
},
];
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
activeOpacity={0.7}
>
<Text style={styles.backIcon}></Text>
<Text style={styles.backLabel}>Voltar</Text>
</TouchableOpacity>
</View>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.titleSection}>
<Text style={styles.title}>Cadastros</Text>
<Text style={styles.subtitle}>
Gerencie os dados base do sistema
</Text>
</View>
<Text style={styles.sectionTitle}>Categorias</Text>
<View style={styles.grid}>
{cards.map((card, index) => (
<TouchableOpacity
key={index}
style={[styles.card, card.highlight && styles.cardHighlight]}
onPress={card.onPress}
activeOpacity={0.75}
>
<Text style={styles.cardIcon}>{card.icon}</Text>
<Text
style={[
styles.cardLabel,
card.highlight && styles.cardLabelHighlight,
]}
>
{card.label}
</Text>
<Text style={styles.cardDescription}>{card.description}</Text>
</TouchableOpacity>
))}
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
header: {
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
backButton: {
flexDirection: "row",
alignItems: "center",
gap: SPACING.xs,
alignSelf: "flex-start",
},
backIcon: {
fontSize: 18,
color: COLORS.text,
},
backLabel: {
fontSize: 15,
color: COLORS.textSecondary,
fontWeight: "500",
},
scrollContent: {
padding: SPACING.lg,
paddingBottom: SPACING.xxl,
},
titleSection: {
marginBottom: SPACING.xl,
marginTop: SPACING.md,
},
title: {
fontSize: 28,
fontWeight: "800",
color: COLORS.text,
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: COLORS.textTertiary,
},
sectionTitle: {
fontSize: 11,
fontWeight: "700",
color: COLORS.textTertiary,
textTransform: "uppercase",
letterSpacing: 1.2,
marginBottom: SPACING.md,
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
gap: SPACING.sm,
},
card: {
width: "48%",
backgroundColor: COLORS.surface,
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.lg,
borderWidth: 1,
borderColor: COLORS.border,
minHeight: 120,
justifyContent: "space-between",
},
cardHighlight: {
backgroundColor: COLORS.surfaceLight,
borderColor: COLORS.text,
},
cardIcon: {
fontSize: 30,
marginBottom: SPACING.sm,
},
cardLabel: {
fontSize: 15,
fontWeight: "700",
color: COLORS.text,
marginBottom: 3,
},
cardLabelHighlight: {
color: COLORS.text,
},
cardDescription: {
fontSize: 11,
color: COLORS.textTertiary,
lineHeight: 15,
},
});

View file

@ -1,121 +1,9 @@
import { useAuth } from "@/contexts/AuthContext";
import { obterCorridas } from "@/services/db";
import React, { useCallback, useEffect, useState } from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import Historico from "./historico";
import Lancamento, { type Corrida } from "./lancamento";
import { router } from "expo-router";
import { useEffect } from "react";
export default function Corrida() {
const { user } = useAuth();
const [activeTab, setActiveTab] = useState<"lancamento" | "historico">(
"lancamento",
);
const [historico, setHistorico] = useState<Corrida[]>([]);
const loadHistorico = useCallback(async () => {
if (!user?.id) return;
try {
const corridasDB = await obterCorridas(user.id);
setHistorico(
corridasDB.map((c) => ({
id: c.id,
data: c.data,
empresa: c.empresa,
km: c.km,
custoPorKm: c.custo_por_km,
total: c.total,
usuario: user.email,
})),
);
} catch (error) {
console.error("Erro ao carregar histórico:", error);
}
}, [user?.id, user?.email]);
useEffect(() => {
loadHistorico();
}, [loadHistorico]);
const handleLancarCorrida = async (_corrida: Corrida) => {
await loadHistorico();
setActiveTab("historico");
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.tabsContainer}>
<TouchableOpacity
style={[styles.tab, activeTab === "lancamento" && styles.tabActive]}
onPress={() => setActiveTab("lancamento")}
>
<Text
style={[
styles.tabLabel,
activeTab === "lancamento" && styles.tabLabelActive,
]}
>
📍 Lançar
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tab, activeTab === "historico" && styles.tabActive]}
onPress={() => setActiveTab("historico")}
>
<Text
style={[
styles.tabLabel,
activeTab === "historico" && styles.tabLabelActive,
]}
>
📋 Histórico
</Text>
</TouchableOpacity>
</View>
<View style={styles.content}>
{activeTab === "lancamento" ? (
<Lancamento onLancar={handleLancarCorrida} />
) : (
<Historico historico={historico} />
)}
</View>
</SafeAreaView>
);
router.replace("/lancamento");
}, []);
return null;
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#ffffff",
},
tabsContainer: {
flexDirection: "row",
backgroundColor: "#ffffff",
borderBottomWidth: 2,
borderBottomColor: "#000000",
},
tab: {
flex: 1,
paddingVertical: 16,
justifyContent: "center",
alignItems: "center",
borderBottomWidth: 3,
borderBottomColor: "transparent",
},
tabActive: {
borderBottomColor: "#000000",
},
tabLabel: {
fontSize: 14,
fontWeight: "600",
color: "#666666",
},
tabLabelActive: {
color: "#000000",
},
content: {
flex: 1,
},
});

View file

@ -0,0 +1,520 @@
import { Input } from "@/components/Input";
import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
import {
EmpresaDB,
atualizarEmpresa,
deletarEmpresa,
obterEmpresas,
salvarEmpresa,
} from "@/services/db";
import { router, useFocusEffect } from "expo-router";
import React, { useCallback, useState } from "react";
import {
Alert,
FlatList,
KeyboardAvoidingView,
Modal,
Platform,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
type FormState = {
nome: string;
custoPorKm: string;
observacoes: string;
};
const FORM_VAZIO: FormState = { nome: "", custoPorKm: "", observacoes: "" };
export default function EmpresasPage() {
const [empresas, setEmpresas] = useState<EmpresaDB[]>([]);
const [modalVisible, setModalVisible] = useState(false);
const [editando, setEditando] = useState<EmpresaDB | null>(null);
const [form, setForm] = useState<FormState>(FORM_VAZIO);
const [loading, setLoading] = useState(false);
const loadEmpresas = useCallback(async () => {
try {
setEmpresas(await obterEmpresas());
} catch {
Alert.alert("Erro", "Não foi possível carregar as empresas.");
}
}, []);
useFocusEffect(useCallback(() => { loadEmpresas(); }, [loadEmpresas]));
const abrirAdicionar = () => {
setEditando(null);
setForm(FORM_VAZIO);
setModalVisible(true);
};
const abrirEditar = (empresa: EmpresaDB) => {
setEditando(empresa);
setForm({
nome: empresa.nome,
custoPorKm: empresa.custo_por_km.toString(),
observacoes: empresa.observacoes ?? "",
});
setModalVisible(true);
};
const fecharModal = () => {
setModalVisible(false);
setEditando(null);
setForm(FORM_VAZIO);
};
const handleSalvar = async () => {
const nomeTrimmed = form.nome.trim();
const custo = parseFloat(form.custoPorKm.replace(",", "."));
if (!nomeTrimmed) {
Alert.alert("Validação", "Informe o nome da empresa.");
return;
}
if (isNaN(custo) || custo <= 0) {
Alert.alert("Validação", "Informe um custo por km válido.");
return;
}
try {
setLoading(true);
if (editando) {
await atualizarEmpresa({
id: editando.id,
nome: nomeTrimmed,
custo_por_km: custo,
observacoes: form.observacoes.trim(),
});
} else {
await salvarEmpresa({
id: Date.now().toString(),
nome: nomeTrimmed,
custo_por_km: custo,
observacoes: form.observacoes.trim(),
});
}
await loadEmpresas();
fecharModal();
} catch {
Alert.alert("Erro", "Não foi possível salvar a empresa.");
} finally {
setLoading(false);
}
};
const handleDeletar = (empresa: EmpresaDB) => {
Alert.alert(
"Excluir empresa",
`Deseja excluir "${empresa.nome}"? Esta ação não pode ser desfeita.`,
[
{ text: "Cancelar", style: "cancel" },
{
text: "Excluir",
style: "destructive",
onPress: async () => {
try {
await deletarEmpresa(empresa.id);
await loadEmpresas();
} catch {
Alert.alert("Erro", "Não foi possível excluir a empresa.");
}
},
},
],
);
};
const renderItem = ({ item, index }: { item: EmpresaDB; index: number }) => (
<View style={styles.item}>
<View style={styles.itemLeft}>
<View style={styles.itemIndex}>
<Text style={styles.itemIndexText}>{index + 1}</Text>
</View>
<View style={styles.itemInfo}>
<Text style={styles.itemNome}>{item.nome}</Text>
<Text style={styles.itemCusto}>R$ {item.custo_por_km.toFixed(2)} / km</Text>
{item.observacoes ? (
<Text style={styles.itemObs} numberOfLines={1}>
{item.observacoes}
</Text>
) : null}
</View>
</View>
<View style={styles.itemActions}>
<TouchableOpacity
style={[styles.actionBtn, styles.editBtn]}
onPress={() => abrirEditar(item)}
activeOpacity={0.7}
>
<Text style={styles.actionBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.deleteBtn]}
onPress={() => handleDeletar(item)}
activeOpacity={0.7}
>
<Text style={styles.actionBtnText}>🗑</Text>
</TouchableOpacity>
</View>
</View>
);
return (
<SafeAreaView style={styles.container}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
activeOpacity={0.7}
>
<Text style={styles.backIcon}></Text>
<Text style={styles.backLabel}>Voltar</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.addButton}
onPress={abrirAdicionar}
activeOpacity={0.8}
>
<Text style={styles.addButtonText}>+ Adicionar</Text>
</TouchableOpacity>
</View>
{/* Title */}
<View style={styles.titleSection}>
<Text style={styles.title}>Empresas</Text>
<Text style={styles.subtitle}>
{empresas.length} empresa{empresas.length !== 1 ? "s" : ""} cadastrada{empresas.length !== 1 ? "s" : ""}
</Text>
</View>
{/* Lista */}
{empresas.length === 0 ? (
<ScrollView contentContainerStyle={styles.emptyContainer}>
<Text style={styles.emptyEmoji}>🏢</Text>
<Text style={styles.emptyTitle}>Nenhuma empresa cadastrada</Text>
<Text style={styles.emptyText}>
Toque em "+ Adicionar" para cadastrar a primeira empresa.
</Text>
</ScrollView>
) : (
<FlatList
data={empresas}
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
/>
)}
{/* Modal Add/Edit */}
<Modal
visible={modalVisible}
animationType="slide"
transparent
onRequestClose={fecharModal}
>
<KeyboardAvoidingView
style={styles.modalOverlay}
behavior={Platform.select({ ios: "padding", android: "height" })}
>
<View style={styles.modalSheet}>
<View style={styles.modalHandle} />
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editando ? "Editar Empresa" : "Nova Empresa"}
</Text>
<TouchableOpacity onPress={fecharModal} activeOpacity={0.7}>
<Text style={styles.modalClose}></Text>
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Nome da Empresa</Text>
<Input
placeholder="Ex: MULTI B"
value={form.nome}
onChangeText={(v) => setForm((f) => ({ ...f, nome: v }))}
autoCapitalize="characters"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Custo por km (R$)</Text>
<Input
placeholder="Ex: 2.70"
keyboardType="decimal-pad"
value={form.custoPorKm}
onChangeText={(v) => setForm((f) => ({ ...f, custoPorKm: v }))}
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Observações (opcional)</Text>
<Input
placeholder="Ex: Pagamento quinzenal"
value={form.observacoes}
onChangeText={(v) => setForm((f) => ({ ...f, observacoes: v }))}
/>
</View>
<TouchableOpacity
style={[styles.saveButton, loading && styles.saveButtonDisabled]}
onPress={handleSalvar}
disabled={loading}
activeOpacity={0.8}
>
<Text style={styles.saveButtonText}>
{loading ? "Salvando..." : editando ? "Salvar alterações" : "Cadastrar empresa"}
</Text>
</TouchableOpacity>
</ScrollView>
</View>
</KeyboardAvoidingView>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
// Header
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
backButton: {
flexDirection: "row",
alignItems: "center",
gap: SPACING.xs,
},
backIcon: {
fontSize: 18,
color: COLORS.text,
},
backLabel: {
fontSize: 15,
color: COLORS.textSecondary,
fontWeight: "500",
},
addButton: {
backgroundColor: COLORS.surface,
borderWidth: 1,
borderColor: COLORS.borderLight,
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
borderRadius: BORDER_RADIUS.md,
},
addButtonText: {
fontSize: 13,
fontWeight: "700",
color: COLORS.text,
},
// Title
titleSection: {
paddingHorizontal: SPACING.lg,
paddingTop: SPACING.lg,
paddingBottom: SPACING.md,
},
title: {
fontSize: 28,
fontWeight: "800",
color: COLORS.text,
marginBottom: 4,
},
subtitle: {
fontSize: 13,
color: COLORS.textTertiary,
},
// List
listContent: {
padding: SPACING.lg,
paddingBottom: SPACING.xxl,
},
item: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: COLORS.surface,
borderRadius: BORDER_RADIUS.lg,
marginBottom: SPACING.sm,
padding: SPACING.md,
borderWidth: 1,
borderColor: COLORS.border,
},
itemLeft: {
flexDirection: "row",
alignItems: "center",
flex: 1,
gap: SPACING.md,
},
itemIndex: {
width: 32,
height: 32,
borderRadius: BORDER_RADIUS.round,
backgroundColor: COLORS.surfaceLight,
justifyContent: "center",
alignItems: "center",
},
itemIndexText: {
fontSize: 12,
fontWeight: "700",
color: COLORS.textTertiary,
},
itemInfo: {
flex: 1,
},
itemNome: {
fontSize: 15,
fontWeight: "700",
color: COLORS.text,
marginBottom: 2,
},
itemCusto: {
fontSize: 13,
color: COLORS.success,
fontWeight: "600",
},
itemObs: {
fontSize: 11,
color: COLORS.textTertiary,
marginTop: 2,
},
itemActions: {
flexDirection: "row",
gap: SPACING.xs,
},
actionBtn: {
width: 36,
height: 36,
borderRadius: BORDER_RADIUS.md,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
},
editBtn: {
backgroundColor: COLORS.surfaceLight,
borderColor: COLORS.border,
},
deleteBtn: {
backgroundColor: COLORS.surfaceLight,
borderColor: COLORS.border,
},
actionBtnText: {
fontSize: 15,
},
// Empty
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: SPACING.lg,
},
emptyEmoji: {
fontSize: 48,
marginBottom: SPACING.lg,
},
emptyTitle: {
fontSize: 18,
fontWeight: "700",
color: COLORS.text,
marginBottom: SPACING.sm,
textAlign: "center",
},
emptyText: {
fontSize: 14,
color: COLORS.textTertiary,
textAlign: "center",
lineHeight: 20,
},
// Modal
modalOverlay: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.7)",
},
modalSheet: {
backgroundColor: COLORS.surface,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
padding: SPACING.lg,
paddingBottom: SPACING.xxl,
borderTopWidth: 1,
borderColor: COLORS.border,
maxHeight: "85%",
},
modalHandle: {
width: 40,
height: 4,
borderRadius: 2,
backgroundColor: COLORS.border,
alignSelf: "center",
marginBottom: SPACING.lg,
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: SPACING.xl,
},
modalTitle: {
fontSize: 20,
fontWeight: "800",
color: COLORS.text,
},
modalClose: {
fontSize: 18,
color: COLORS.textTertiary,
padding: SPACING.xs,
},
// Form
formGroup: {
marginBottom: SPACING.lg,
},
formLabel: {
fontSize: 11,
fontWeight: "700",
color: COLORS.textTertiary,
textTransform: "uppercase",
letterSpacing: 0.8,
marginBottom: SPACING.sm,
},
saveButton: {
backgroundColor: COLORS.text,
borderRadius: BORDER_RADIUS.lg,
paddingVertical: SPACING.lg,
alignItems: "center",
marginTop: SPACING.sm,
},
saveButtonDisabled: {
backgroundColor: COLORS.borderLight,
},
saveButtonText: {
fontSize: 15,
fontWeight: "700",
color: COLORS.background,
},
});

View file

@ -1,21 +1,27 @@
import React from "react";
import { FlatList, ScrollView, StyleSheet, Text, View } from "react-native";
import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
import { useAuth } from "@/contexts/AuthContext";
import { obterCorridas } from "@/services/db";
import { router, useFocusEffect } from "expo-router";
import React, { useCallback, useState } from "react";
import {
FlatList,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
export type Corrida = {
type Corrida = {
id: string;
data: string;
empresa: string;
km: number;
custoPorKm: number;
total: number;
usuario: string;
};
interface HistoricoProps {
historico: Corrida[];
}
const HistoricoItem = ({ item, index }: { item: Corrida; index: number }) => (
<View style={styles.item}>
<View style={styles.itemHeader}>
@ -32,41 +38,92 @@ const HistoricoItem = ({ item, index }: { item: Corrida; index: number }) => (
<Text style={styles.detailLabel}>Distância</Text>
<Text style={styles.detailValue}>{item.km} km</Text>
</View>
<View style={styles.detailBox}>
<Text style={styles.detailLabel}>Valor/km</Text>
<Text style={styles.detailValue}>R$ {item.custoPorKm.toFixed(2)}</Text>
</View>
<View style={styles.detailBox}>
<View style={[styles.detailBox, styles.detailBoxHighlight]}>
<Text style={styles.detailLabel}>Total</Text>
<Text style={styles.detailValue}>R$ {item.total.toFixed(2)}</Text>
<Text style={[styles.detailValue, styles.detailValueHighlight]}>
R$ {item.total.toFixed(2)}
</Text>
</View>
</View>
</View>
);
export default function Historico({ historico }: HistoricoProps) {
export default function HistoricoPage() {
const { user } = useAuth();
const [corridas, setCorridas] = useState<Corrida[]>([]);
const loadCorridas = useCallback(async () => {
if (!user?.id) return;
try {
const data = await obterCorridas(user.id);
setCorridas(
data.map((c) => ({
id: c.id,
data: c.data,
empresa: c.empresa,
km: c.km,
custoPorKm: c.custo_por_km,
total: c.total,
})),
);
} catch (error) {
console.error("Erro ao carregar histórico:", error);
}
}, [user?.id]);
useFocusEffect(useCallback(() => { loadCorridas(); }, [loadCorridas]));
const totalGanhos = corridas.reduce((acc, c) => acc + c.total, 0);
const totalKm = corridas.reduce((acc, c) => acc + c.km, 0);
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Histórico de Corridas</Text>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton} activeOpacity={0.7}>
<Text style={styles.backIcon}></Text>
<Text style={styles.backLabel}>Voltar</Text>
</TouchableOpacity>
</View>
<View style={styles.titleSection}>
<Text style={styles.title}>Histórico</Text>
<Text style={styles.subtitle}>
{historico.length} corrida{historico.length !== 1 ? "s" : ""}
{corridas.length} corrida{corridas.length !== 1 ? "s" : ""} registrada{corridas.length !== 1 ? "s" : ""}
</Text>
</View>
{historico.length === 0 ? (
{corridas.length > 0 && (
<View style={styles.summaryRow}>
<View style={styles.summaryCard}>
<Text style={styles.summaryValue}>{totalKm.toFixed(0)} km</Text>
<Text style={styles.summaryLabel}>Total Rodado</Text>
</View>
<View style={[styles.summaryCard, styles.summaryCardAccent]}>
<Text style={[styles.summaryValue, styles.summaryValueAccent]}>
R$ {totalGanhos.toFixed(2)}
</Text>
<Text style={styles.summaryLabel}>Total Ganho</Text>
</View>
</View>
)}
{corridas.length === 0 ? (
<ScrollView contentContainerStyle={styles.emptyContainer}>
<View style={styles.emptyBox}>
<Text style={styles.emptyEmoji}>🚗</Text>
<Text style={styles.emptyTitle}>Nenhuma corrida registrada</Text>
<Text style={styles.emptyText}>Suas corridas aparecerão aqui</Text>
<Text style={styles.emptyTitle}>Nenhuma corrida ainda</Text>
<Text style={styles.emptyText}>
Registre sua primeira corrida para -la aqui.
</Text>
</View>
</ScrollView>
) : (
<FlatList
data={historico}
data={corridas}
keyExtractor={(item) => item.id}
renderItem={({ item, index }) => (
<HistoricoItem item={item} index={index} />
@ -82,110 +139,170 @@ export default function Historico({ historico }: HistoricoProps) {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#ffffff",
backgroundColor: COLORS.background,
},
header: {
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 24,
backgroundColor: "#ffffff",
borderBottomWidth: 2,
borderBottomColor: "#000000",
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
backButton: {
flexDirection: "row",
alignItems: "center",
gap: SPACING.xs,
alignSelf: "flex-start",
},
backIcon: {
fontSize: 18,
color: COLORS.text,
},
backLabel: {
fontSize: 15,
color: COLORS.textSecondary,
fontWeight: "500",
},
titleSection: {
paddingHorizontal: SPACING.lg,
paddingTop: SPACING.lg,
paddingBottom: SPACING.md,
},
title: {
fontSize: 28,
fontWeight: "800",
color: "#000000",
color: COLORS.text,
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: "#333333",
fontSize: 13,
color: COLORS.textTertiary,
},
summaryRow: {
flexDirection: "row",
gap: SPACING.sm,
paddingHorizontal: SPACING.lg,
paddingBottom: SPACING.lg,
},
summaryCard: {
flex: 1,
backgroundColor: COLORS.surface,
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.md,
borderWidth: 1,
borderColor: COLORS.border,
},
summaryCardAccent: {
borderColor: COLORS.success,
},
summaryValue: {
fontSize: 18,
fontWeight: "800",
color: COLORS.text,
marginBottom: 2,
},
summaryValueAccent: {
color: COLORS.success,
},
summaryLabel: {
fontSize: 11,
color: COLORS.textTertiary,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.5,
},
listContent: {
padding: 16,
paddingBottom: 32,
padding: SPACING.lg,
paddingBottom: SPACING.xxl,
},
item: {
backgroundColor: "#ffffff",
borderRadius: 12,
marginBottom: 12,
backgroundColor: COLORS.surface,
borderRadius: BORDER_RADIUS.lg,
marginBottom: SPACING.md,
overflow: "hidden",
borderWidth: 1,
borderColor: "#000000",
borderColor: COLORS.border,
},
itemHeader: {
flexDirection: "row",
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 8,
paddingHorizontal: SPACING.lg,
paddingTop: SPACING.md,
paddingBottom: SPACING.sm,
justifyContent: "space-between",
alignItems: "center",
borderBottomWidth: 1,
borderBottomColor: "#e0e0e0",
borderBottomColor: COLORS.border,
},
itemNumber: {
fontSize: 12,
fontWeight: "700",
color: "#000000",
color: COLORS.textTertiary,
letterSpacing: 0.5,
},
itemDate: {
fontSize: 12,
color: "#666666",
color: COLORS.textTertiary,
},
itemEmpresa: {
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: "#f5f5f5",
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.sm,
backgroundColor: COLORS.surfaceLight,
},
empresaLabel: {
fontSize: 14,
fontWeight: "600",
color: "#000000",
fontWeight: "700",
color: COLORS.text,
},
itemDetails: {
flexDirection: "row",
paddingHorizontal: 16,
paddingVertical: 12,
gap: 8,
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
},
detailBox: {
flex: 1,
paddingVertical: 8,
},
detailBoxHighlight: {
alignItems: "flex-end",
},
detailLabel: {
fontSize: 11,
color: "#666666",
fontWeight: "500",
marginBottom: 2,
fontSize: 10,
color: COLORS.textTertiary,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.4,
marginBottom: 3,
},
detailValue: {
fontSize: 14,
fontWeight: "700",
color: "#000000",
color: COLORS.text,
},
detailValueHighlight: {
color: COLORS.success,
fontSize: 16,
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 16,
padding: SPACING.lg,
},
emptyBox: {
alignItems: "center",
},
emptyEmoji: {
fontSize: 48,
marginBottom: 16,
marginBottom: SPACING.lg,
},
emptyTitle: {
fontSize: 18,
fontWeight: "600",
color: "#000000",
marginBottom: 8,
fontWeight: "700",
color: COLORS.text,
marginBottom: SPACING.sm,
},
emptyText: {
fontSize: 14,
color: "#666666",
color: COLORS.textTertiary,
textAlign: "center",
lineHeight: 20,
},
});

View file

@ -101,14 +101,14 @@ export default function Home() {
icon: "🚗",
label: "Registrar Corrida",
description: "Lançar nova corrida",
onPress: () => router.push("/corrida"),
onPress: () => router.push("/lancamento"),
highlight: true,
},
{
icon: "📋",
label: "Histórico",
description: "Ver corridas anteriores",
onPress: () => router.push("/corrida"),
onPress: () => router.push("/historico"),
},
{
icon: "📊",
@ -116,12 +116,24 @@ export default function Home() {
description: "Análise de ganhos",
onPress: () => Alert.alert("Em breve", "Funcionalidade em desenvolvimento."),
},
{
icon: "👤",
label: "Cadastros",
description: "Cadastros de dados básicos",
onPress: () => router.push("/cadastros"),
},
{
icon: "⚙️",
label: "Configurações",
description: "Preferências do app",
onPress: () => Alert.alert("Em breve", "Funcionalidade em desenvolvimento."),
},
{
icon: "🔄",
label: "Sincronizar",
description: "Sincronizar dados para portal",
onPress: () => router.push("/sincronizar"),
},
];
return (

View file

@ -61,20 +61,20 @@ export default function IndexPage() {
setAuthToken(data.accessToken);
// Salvar no SQLite
const userId = Date.now().toString();
await salvarUsuario({
id: userId,
const userName = data.name || emailTrimmed.split("@")[0];
// Salvar no SQLite — retorna o ID existente se o usuário já tinha conta
const userId = await salvarUsuario({
id: Date.now().toString(),
email: emailTrimmed,
name: data.name || emailTrimmed.split("@")[0],
name: userName,
token: data.accessToken,
});
// Fazer login no contexto
await login(emailTrimmed, passwordTrimmed, data.accessToken, {
id: userId,
email: emailTrimmed,
name: data.name || emailTrimmed.split("@")[0],
name: userName,
});
} catch (err: any) {
const message = err.response?.data?.error ?? err.message ?? "Erro ao fazer login.";

View file

@ -1,9 +1,11 @@
import { Button } from "@/components/Button";
import { Input } from "@/components/Input";
import { Select } from "@/components/Select";
import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
import { useAuth } from "@/contexts/AuthContext";
import { salvarCorrida } from "@/services/db";
import React, { useState } from "react";
import { EmpresaDB, obterEmpresas, salvarCorrida } from "@/services/db";
import { router, useFocusEffect } from "expo-router";
import React, { useCallback, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
@ -11,19 +13,11 @@ import {
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
const EMPRESAS = [
{ label: "Selecione uma empresa", value: "" },
{ label: "MULTI B", value: "MULTI_B" },
{ label: "TOP TRANS", value: "TOP_TRANS" },
{ label: "LOGISTICA XYZ", value: "LOGISTICA_XYZ" },
];
const CUSTO_POR_KM = 2.7;
export type Corrida = {
id: string;
data: string;
@ -34,63 +28,83 @@ export type Corrida = {
usuario: string;
};
interface LancamentoProps {
onLancar: (corrida: Corrida) => void;
}
export default function Lancamento({ onLancar }: LancamentoProps) {
export default function LancamentoPage() {
const { user } = useAuth();
const [empresa, setEmpresa] = useState("");
const [empresasDB, setEmpresasDB] = useState<EmpresaDB[]>([]);
const [empresaId, setEmpresaId] = useState("");
const [custoPorKm, setCustoPorKm] = useState(0);
const [distancia, setDistancia] = useState("");
const [loading, setLoading] = useState(false);
const distanciaNum = parseFloat(distancia) || 0;
const totalCorrida = distanciaNum * CUSTO_POR_KM;
const totalCorrida = distanciaNum * custoPorKm;
useFocusEffect(
useCallback(() => {
obterEmpresas()
.then(setEmpresasDB)
.catch(() => Alert.alert("Erro", "Não foi possível carregar as empresas."));
}, []),
);
const empresasItems = [
{ label: "Selecione uma empresa", value: "" },
...empresasDB.map((e) => ({ label: e.nome, value: e.id })),
];
const handleEmpresaChange = (id: string) => {
setEmpresaId(id);
const found = empresasDB.find((e) => e.id === id);
setCustoPorKm(found?.custo_por_km ?? 0);
};
const handleLancarCorrida = async () => {
if (!empresa) {
if (!empresaId) {
Alert.alert("Validação", "Por favor, selecione uma empresa.");
return;
}
if (!distancia || distanciaNum <= 0) {
Alert.alert("Validação", "Por favor, insira uma distância válida.");
return;
}
const empresaSelecionada = EMPRESAS.find((e) => e.value === empresa);
const novaCorrida: Corrida = {
id: Date.now().toString(),
data: new Date().toLocaleString("pt-BR"),
empresa: empresaSelecionada?.label || empresa,
km: distanciaNum,
custoPorKm: CUSTO_POR_KM,
total: totalCorrida,
usuario: user?.email ?? "",
};
const empresaSelecionada = empresasDB.find((e) => e.id === empresaId);
try {
setLoading(true);
await salvarCorrida({
id: novaCorrida.id,
id: Date.now().toString(),
usuario_id: user?.id ?? "",
empresa: novaCorrida.empresa,
km: novaCorrida.km,
custo_por_km: novaCorrida.custoPorKm,
total: novaCorrida.total,
data: novaCorrida.data,
empresa: empresaSelecionada?.nome ?? empresaId,
km: distanciaNum,
custo_por_km: custoPorKm,
total: totalCorrida,
data: new Date().toLocaleString("pt-BR"),
sincronizado: 0,
});
setEmpresaId("");
setCustoPorKm(0);
setDistancia("");
Alert.alert("Sucesso", "Corrida registrada com sucesso!", [
{ text: "OK", onPress: () => router.back() },
]);
} catch (error) {
console.error("Erro ao salvar corrida:", error);
Alert.alert("Erro", "Não foi possível registrar a corrida.");
} finally {
setLoading(false);
}
onLancar(novaCorrida);
setEmpresa("");
setDistancia("");
Alert.alert("Sucesso", "Corrida registrada com sucesso!");
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton} activeOpacity={0.7}>
<Text style={styles.backIcon}></Text>
<Text style={styles.backLabel}>Voltar</Text>
</TouchableOpacity>
</View>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.select({ ios: "padding", android: "height" })}
@ -99,18 +113,14 @@ export default function Lancamento({ onLancar }: LancamentoProps) {
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<View style={styles.titleSection}>
<Text style={styles.title}>Nova Corrida</Text>
<Text style={styles.subtitle}>Preencha os dados da corrida</Text>
<Text style={styles.subtitle}>Preencha os dados abaixo</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardLabel}>Empresa</Text>
<Select
value={empresa}
onValueChange={setEmpresa}
items={EMPRESAS}
/>
<Select value={empresaId} onValueChange={handleEmpresaChange} items={empresasItems} />
</View>
<View style={styles.card}>
@ -124,24 +134,22 @@ export default function Lancamento({ onLancar }: LancamentoProps) {
</View>
<View style={styles.card}>
<View style={styles.valorRow}>
<Text style={styles.valorLabel}>Custo por km</Text>
<Text style={styles.valorAmount}>
R$ {CUSTO_POR_KM.toFixed(2)}
</Text>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Custo por km</Text>
<Text style={styles.infoValue}>R$ {custoPorKm.toFixed(2)}</Text>
</View>
</View>
<View style={styles.card}>
<View style={styles.totalBox}>
<Text style={styles.totalLabel}>Valor Total</Text>
<Text style={styles.totalAmount}>
R$ {totalCorrida.toFixed(2)}
</Text>
</View>
<View style={[styles.card, styles.totalCard]}>
<Text style={styles.totalLabel}>Valor Total</Text>
<Text style={styles.totalAmount}>R$ {totalCorrida.toFixed(2)}</Text>
</View>
<Button label="Lançar Corrida" onPress={handleLancarCorrida} />
<Button
label={loading ? "Salvando..." : "Lançar Corrida"}
onPress={handleLancarCorrida}
disabled={loading}
/>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
@ -151,72 +159,95 @@ export default function Lancamento({ onLancar }: LancamentoProps) {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#ffffff",
},
scrollContent: {
padding: 16,
paddingBottom: 32,
backgroundColor: COLORS.background,
},
header: {
marginBottom: 24,
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
backButton: {
flexDirection: "row",
alignItems: "center",
gap: SPACING.xs,
alignSelf: "flex-start",
},
backIcon: {
fontSize: 18,
color: COLORS.text,
},
backLabel: {
fontSize: 15,
color: COLORS.textSecondary,
fontWeight: "500",
},
scrollContent: {
padding: SPACING.lg,
paddingBottom: SPACING.xxl,
},
titleSection: {
marginBottom: SPACING.xl,
marginTop: SPACING.md,
},
title: {
fontSize: 28,
fontWeight: "800",
color: "#000000",
color: COLORS.text,
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: "#333333",
color: COLORS.textTertiary,
},
card: {
backgroundColor: "#ffffff",
borderRadius: 12,
padding: 16,
marginBottom: 12,
backgroundColor: COLORS.surface,
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.lg,
marginBottom: SPACING.md,
borderWidth: 1,
borderColor: "#000000",
borderColor: COLORS.border,
},
cardLabel: {
fontSize: 12,
fontWeight: "600",
color: "#000000",
marginBottom: 8,
fontSize: 11,
fontWeight: "700",
color: COLORS.textTertiary,
marginBottom: SPACING.sm,
textTransform: "uppercase",
letterSpacing: 0.5,
letterSpacing: 0.8,
},
valorRow: {
infoRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
valorLabel: {
infoLabel: {
fontSize: 14,
color: "#000000",
color: COLORS.textSecondary,
fontWeight: "500",
},
valorAmount: {
infoValue: {
fontSize: 16,
fontWeight: "700",
color: "#000000",
color: COLORS.text,
},
totalBox: {
backgroundColor: "#ffffff",
borderLeftWidth: 4,
borderLeftColor: "#000000",
padding: 12,
borderRadius: 8,
totalCard: {
borderColor: COLORS.borderLight,
borderLeftWidth: 3,
borderLeftColor: COLORS.success,
marginBottom: SPACING.xl,
},
totalLabel: {
fontSize: 12,
color: "#000000",
marginBottom: 4,
fontWeight: "500",
color: COLORS.textTertiary,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.5,
marginBottom: SPACING.xs,
},
totalAmount: {
fontSize: 24,
fontSize: 32,
fontWeight: "800",
color: "#000000",
color: COLORS.success,
},
});

View file

@ -0,0 +1,500 @@
import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
import { useAuth } from "@/contexts/AuthContext";
import {
marcarCorridaComoSincronizada,
obterCorridasNaoSincronizadas,
obterEmpresas,
obterUsuario,
} from "@/services/db";
import { api } from "@/server/api";
import { router } from "expo-router";
import React, { useState } from "react";
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
type SyncStatus = "pending" | "syncing" | "success" | "error";
type SyncItem = {
key: string;
label: string;
description: string;
status: SyncStatus;
detail: string;
};
const INITIAL_ITEMS: SyncItem[] = [
{
key: "empresas",
label: "Empresas",
description: "Cadastro de empresas e custos por km",
status: "pending",
detail: "",
},
{
key: "usuarios",
label: "Usuários",
description: "Dados do usuário autenticado",
status: "pending",
detail: "",
},
{
key: "corridas",
label: "Corridas",
description: "Histórico de corridas não sincronizadas",
status: "pending",
detail: "",
},
];
export default function SincronizarPage() {
const { user } = useAuth();
const [items, setItems] = useState<SyncItem[]>(INITIAL_ITEMS);
const [syncing, setSyncing] = useState(false);
const [done, setDone] = useState(false);
const update = (key: string, patch: Partial<SyncItem>) =>
setItems((prev) =>
prev.map((i) => (i.key === key ? { ...i, ...patch } : i)),
);
const reset = () => {
setItems(INITIAL_ITEMS);
setDone(false);
};
const handleSync = async () => {
if (!user?.id) return;
setSyncing(true);
setDone(false);
setItems(INITIAL_ITEMS);
// ── Empresas ────────────────────────────────────────────
update("empresas", { status: "syncing" });
try {
const empresas = await obterEmpresas();
await api.post("/sync/empresas", { empresas });
update("empresas", {
status: "success",
detail: `${empresas.length} registro${empresas.length !== 1 ? "s" : ""} enviado${empresas.length !== 1 ? "s" : ""}`,
});
} catch (e: any) {
update("empresas", {
status: "error",
detail: e?.response?.data?.error ?? e?.message ?? "Falha na conexão",
});
}
// ── Usuários ─────────────────────────────────────────────
update("usuarios", { status: "syncing" });
try {
const usuario = await obterUsuario(user.id);
if (usuario) {
await api.post("/sync/usuarios", { usuarios: [usuario] });
update("usuarios", { status: "success", detail: "1 registro enviado" });
} else {
update("usuarios", { status: "error", detail: "Usuário não encontrado" });
}
} catch (e: any) {
update("usuarios", {
status: "error",
detail: e?.response?.data?.error ?? e?.message ?? "Falha na conexão",
});
}
// ── Corridas ─────────────────────────────────────────────
update("corridas", { status: "syncing" });
try {
const corridas = await obterCorridasNaoSincronizadas(user.id);
if (corridas.length === 0) {
update("corridas", { status: "success", detail: "Nenhuma corrida pendente" });
} else {
await api.post("/sync/corridas", { corridas });
for (const c of corridas) {
await marcarCorridaComoSincronizada(c.id);
}
update("corridas", {
status: "success",
detail: `${corridas.length} corrida${corridas.length !== 1 ? "s" : ""} enviada${corridas.length !== 1 ? "s" : ""}`,
});
}
} catch (e: any) {
update("corridas", {
status: "error",
detail: e?.response?.data?.error ?? e?.message ?? "Falha na conexão",
});
}
setSyncing(false);
setDone(true);
};
const allSuccess = items.every((i) => i.status === "success");
const hasError = items.some((i) => i.status === "error");
return (
<SafeAreaView style={styles.container}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
activeOpacity={0.7}
>
<Text style={styles.backIcon}></Text>
<Text style={styles.backLabel}>Voltar</Text>
</TouchableOpacity>
</View>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.titleSection}>
<Text style={styles.title}>Sincronizar</Text>
<Text style={styles.subtitle}>
Enviar dados locais para o servidor
</Text>
</View>
{/* Status geral após conclusão */}
{done && (
<View
style={[
styles.resultBanner,
allSuccess ? styles.resultBannerSuccess : styles.resultBannerError,
]}
>
<Text style={styles.resultBannerText}>
{allSuccess
? "✓ Todos os dados sincronizados com sucesso"
: "⚠ Sincronização concluída com erros"}
</Text>
</View>
)}
{/* Timeline */}
<View style={styles.timeline}>
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<View key={item.key} style={styles.timelineRow}>
{/* Track: linha + círculo */}
<View style={styles.track}>
<NodeCircle status={item.status} />
{!isLast && (
<View
style={[
styles.connector,
item.status === "success" && styles.connectorSuccess,
item.status === "error" && styles.connectorError,
]}
/>
)}
</View>
{/* Conteúdo */}
<View style={styles.itemContent}>
<View style={styles.itemHeader}>
<Text style={styles.itemLabel}>{item.label}</Text>
{item.status === "success" && (
<Text style={styles.badgeSuccess}> OK</Text>
)}
{item.status === "error" && (
<Text style={styles.badgeError}> Erro</Text>
)}
</View>
<Text style={styles.itemDescription}>{item.description}</Text>
{item.detail !== "" && (
<Text
style={[
styles.itemDetail,
item.status === "success" && styles.itemDetailSuccess,
item.status === "error" && styles.itemDetailError,
]}
>
{item.detail}
</Text>
)}
</View>
</View>
);
})}
</View>
{/* Botões */}
<View style={styles.actions}>
{done && hasError && (
<TouchableOpacity
style={styles.retryButton}
onPress={reset}
activeOpacity={0.8}
>
<Text style={styles.retryButtonText}>Redefinir</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[
styles.syncButton,
syncing && styles.syncButtonDisabled,
]}
onPress={handleSync}
disabled={syncing}
activeOpacity={0.85}
>
{syncing ? (
<ActivityIndicator color={COLORS.background} size="small" />
) : (
<Text style={styles.syncButtonText}>
{done ? "Sincronizar novamente" : "Iniciar sincronização"}
</Text>
)}
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
);
}
function NodeCircle({ status }: { status: SyncStatus }) {
if (status === "syncing") {
return (
<View style={[styles.circle, styles.circleSyncing]}>
<ActivityIndicator color={COLORS.warning} size="small" />
</View>
);
}
return (
<View
style={[
styles.circle,
status === "success" && styles.circleSuccess,
status === "error" && styles.circleError,
status === "pending" && styles.circlePending,
]}
>
{status === "success" && <Text style={styles.circleIcon}></Text>}
{status === "error" && <Text style={styles.circleIcon}></Text>}
</View>
);
}
const CIRCLE_SIZE = 36;
const CONNECTOR_WIDTH = 2;
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
// Header
header: {
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
backButton: {
flexDirection: "row",
alignItems: "center",
gap: SPACING.xs,
alignSelf: "flex-start",
},
backIcon: { fontSize: 18, color: COLORS.text },
backLabel: { fontSize: 15, color: COLORS.textSecondary, fontWeight: "500" },
scrollContent: {
padding: SPACING.lg,
paddingBottom: 48,
},
titleSection: {
marginBottom: SPACING.xl,
marginTop: SPACING.md,
},
title: {
fontSize: 28,
fontWeight: "800",
color: COLORS.text,
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: COLORS.textTertiary,
},
// Banner de resultado
resultBanner: {
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.md,
marginBottom: SPACING.xl,
borderWidth: 1,
},
resultBannerSuccess: {
backgroundColor: "#0d2b1f",
borderColor: COLORS.success,
},
resultBannerError: {
backgroundColor: "#2b0d0d",
borderColor: COLORS.error,
},
resultBannerText: {
fontSize: 14,
fontWeight: "600",
color: COLORS.text,
},
// Timeline
timeline: {
marginBottom: SPACING.xl,
},
timelineRow: {
flexDirection: "row",
alignItems: "flex-start",
},
// Track (círculo + conector)
track: {
alignItems: "center",
width: CIRCLE_SIZE + SPACING.lg,
},
circle: {
width: CIRCLE_SIZE,
height: CIRCLE_SIZE,
borderRadius: CIRCLE_SIZE / 2,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
zIndex: 1,
},
circlePending: {
backgroundColor: COLORS.surface,
borderColor: COLORS.border,
},
circleSyncing: {
backgroundColor: COLORS.surface,
borderColor: COLORS.warning,
},
circleSuccess: {
backgroundColor: "#0d2b1f",
borderColor: COLORS.success,
},
circleError: {
backgroundColor: "#2b0d0d",
borderColor: COLORS.error,
},
circleIcon: {
fontSize: 16,
fontWeight: "800",
color: COLORS.text,
},
connector: {
width: CONNECTOR_WIDTH,
flex: 1,
minHeight: 56,
backgroundColor: COLORS.border,
marginTop: 2,
marginBottom: 2,
},
connectorSuccess: {
backgroundColor: COLORS.success,
},
connectorError: {
backgroundColor: COLORS.error,
},
// Item content
itemContent: {
flex: 1,
paddingTop: SPACING.xs,
paddingBottom: SPACING.xl,
paddingLeft: SPACING.md,
},
itemHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 3,
},
itemLabel: {
fontSize: 16,
fontWeight: "700",
color: COLORS.text,
},
itemDescription: {
fontSize: 12,
color: COLORS.textTertiary,
marginBottom: 4,
},
itemDetail: {
fontSize: 12,
fontWeight: "600",
color: COLORS.textSecondary,
},
itemDetailSuccess: {
color: COLORS.success,
},
itemDetailError: {
color: COLORS.error,
},
// Badges
badgeSuccess: {
fontSize: 12,
fontWeight: "700",
color: COLORS.success,
backgroundColor: "#0d2b1f",
paddingHorizontal: SPACING.sm,
paddingVertical: 2,
borderRadius: BORDER_RADIUS.sm,
},
badgeError: {
fontSize: 12,
fontWeight: "700",
color: COLORS.error,
backgroundColor: "#2b0d0d",
paddingHorizontal: SPACING.sm,
paddingVertical: 2,
borderRadius: BORDER_RADIUS.sm,
},
// Botões
actions: {
gap: SPACING.sm,
},
syncButton: {
backgroundColor: COLORS.text,
borderRadius: BORDER_RADIUS.lg,
paddingVertical: SPACING.lg,
alignItems: "center",
},
syncButtonDisabled: {
backgroundColor: COLORS.borderLight,
},
syncButtonText: {
fontSize: 15,
fontWeight: "700",
color: COLORS.background,
},
retryButton: {
backgroundColor: COLORS.surface,
borderRadius: BORDER_RADIUS.lg,
paddingVertical: SPACING.md,
alignItems: "center",
borderWidth: 1,
borderColor: COLORS.border,
},
retryButtonText: {
fontSize: 14,
fontWeight: "600",
color: COLORS.textSecondary,
},
});

View file

@ -24,16 +24,17 @@ export type CorridaDB = {
export const initDB = async () => {
try {
await db.execAsync(`
CREATE TABLE IF NOT EXISTS usuarios (
await db.execAsync(
`CREATE TABLE IF NOT EXISTS usuarios (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
token TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS corridas (
);`,
);
await db.execAsync(
`CREATE TABLE IF NOT EXISTS corridas (
id TEXT PRIMARY KEY,
usuario_id TEXT NOT NULL,
empresa TEXT NOT NULL,
@ -44,13 +45,23 @@ export const initDB = async () => {
sincronizado INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (usuario_id) REFERENCES usuarios(id)
);
CREATE TABLE IF NOT EXISTS settings (
);`,
);
await db.execAsync(
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
);`,
);
await db.execAsync(
`CREATE TABLE IF NOT EXISTS empresas (
id TEXT PRIMARY KEY,
nome TEXT NOT NULL,
custo_por_km REAL NOT NULL,
observacoes TEXT DEFAULT '',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);`,
);
} catch (error) {
console.error("Failed to initialize database:", error);
throw error;
@ -93,14 +104,29 @@ export const deleteSetting = async (key: string): Promise<void> => {
};
// USUÁRIOS
export const salvarUsuario = async (usuario: Omit<UsuarioDB, "created_at">) => {
export const salvarUsuario = async (
usuario: Omit<UsuarioDB, "created_at">,
): Promise<string> => {
try {
const result = await db.runAsync(
`INSERT OR REPLACE INTO usuarios (id, email, name, token)
VALUES (?, ?, ?, ?)`,
const existing = await db.getFirstAsync<{ id: string }>(
`SELECT id FROM usuarios WHERE email = ?`,
[usuario.email],
);
if (existing) {
// Preserva o ID original — apenas atualiza nome e token
await db.runAsync(
`UPDATE usuarios SET name = ?, token = ? WHERE email = ?`,
[usuario.name, usuario.token, usuario.email],
);
return existing.id;
}
await db.runAsync(
`INSERT INTO usuarios (id, email, name, token) VALUES (?, ?, ?, ?)`,
[usuario.id, usuario.email, usuario.name, usuario.token],
);
return result;
return usuario.id;
} catch (error) {
console.error("Error saving user:", error);
throw error;
@ -205,3 +231,62 @@ export const limparBancoDados = async () => {
throw error;
}
};
// EMPRESAS
export type EmpresaDB = {
id: string;
nome: string;
custo_por_km: number;
observacoes: string;
created_at: string;
};
export const obterEmpresas = async (): Promise<EmpresaDB[]> => {
try {
return (
(await db.getAllAsync<EmpresaDB>(
`SELECT * FROM empresas ORDER BY nome ASC`,
)) ?? []
);
} catch (error) {
console.error("Error fetching empresas:", error);
throw error;
}
};
export const salvarEmpresa = async (
empresa: Omit<EmpresaDB, "created_at">,
): Promise<void> => {
try {
await db.runAsync(
`INSERT INTO empresas (id, nome, custo_por_km, observacoes) VALUES (?, ?, ?, ?)`,
[empresa.id, empresa.nome, empresa.custo_por_km, empresa.observacoes],
);
} catch (error) {
console.error("Error saving empresa:", error);
throw error;
}
};
export const atualizarEmpresa = async (
empresa: Omit<EmpresaDB, "created_at">,
): Promise<void> => {
try {
await db.runAsync(
`UPDATE empresas SET nome = ?, custo_por_km = ?, observacoes = ? WHERE id = ?`,
[empresa.nome, empresa.custo_por_km, empresa.observacoes, empresa.id],
);
} catch (error) {
console.error("Error updating empresa:", error);
throw error;
}
};
export const deletarEmpresa = async (id: string): Promise<void> => {
try {
await db.runAsync(`DELETE FROM empresas WHERE id = ?`, [id]);
} catch (error) {
console.error("Error deleting empresa:", error);
throw error;
}
};