chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
@@ -0,0 +1,401 @@
import React, { useState, useCallback, useMemo } from "react";
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
RefreshControl,
} from "react-native";
import { useNavigation, useFocusEffect } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { Ionicons } from "@expo/vector-icons";
import {
getMyCompletedOrders,
getMyPenalties,
formatOrderDate,
formatPrice,
} from "../../api/api";
import type {
CompletedOrder,
ClientStats,
PenaltyInfo,
} from "../../api/api_types";
import type { ClientStackParamList } from "../../navigation/types";
import StatusBadge from "../../components/StatusBadge";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
import { useTheme } from "../../context/ThemeContext";
import {
spacing,
borderRadius,
fontSize,
fontWeight,
shadows,
} from "../../theme";
type Nav = NativeStackNavigationProp<ClientStackParamList>;
export default function OrderHistoryScreen() {
const navigation = useNavigation<Nav>();
const { colors } = useTheme();
const [orders, setOrders] = useState<CompletedOrder[]>([]);
const [stats, setStats] = useState<ClientStats | null>(null);
const [penalties, setPenalties] = useState<PenaltyInfo | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const fetchData = useCallback(async () => {
try {
const [histRes, penRes] = await Promise.all([
getMyCompletedOrders(),
getMyPenalties(),
]);
if (histRes.success) {
setOrders(histRes.commands || []);
setStats(histRes.client_stats || null);
}
if (penRes.success) {
setPenalties(penRes.data || null);
}
} catch {
/* ignore */
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useFocusEffect(
useCallback(() => {
setLoading(true);
fetchData();
}, [fetchData]),
);
const onRefresh = () => {
setRefreshing(true);
fetchData();
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
listContent: {
padding: spacing.l,
paddingBottom: spacing.xxxl,
},
statsGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.m,
marginBottom: spacing.xl,
},
statCard: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: spacing.l,
alignItems: "center",
flex: 1,
minWidth: "45%",
borderWidth: 1,
borderColor: colors.borderLight,
},
statValue: {
color: colors.textWhite,
fontSize: fontSize.xxl,
fontWeight: fontWeight.bold,
marginTop: spacing.s,
},
statLabel: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: spacing.xs,
},
penaltyBanner: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
padding: spacing.m,
borderRadius: borderRadius.sm,
marginBottom: spacing.xl,
},
penaltyWarning: {
backgroundColor: colors.warning + "22",
borderWidth: 1,
borderColor: colors.warning + "44",
},
penaltyCritical: {
backgroundColor: colors.danger + "22",
borderWidth: 1,
borderColor: colors.danger + "44",
},
penaltyText: {
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
},
sectionTitle: {
color: colors.textSecondary,
fontSize: fontSize.sm,
fontWeight: fontWeight.medium,
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: spacing.m,
},
emptyContainer: {
alignItems: "center",
paddingTop: spacing.xxxl,
},
emptyTitle: {
color: colors.textPrimary,
fontSize: fontSize.xl,
fontWeight: fontWeight.bold,
marginTop: spacing.l,
},
emptySubtitle: {
color: colors.textMuted,
fontSize: fontSize.md,
marginTop: spacing.s,
},
orderCard: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: spacing.l,
marginBottom: spacing.m,
borderWidth: 1,
borderColor: colors.borderLight,
},
orderHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.m,
},
orderIdText: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
orderRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.xs,
},
orderDetail: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginLeft: spacing.s,
flex: 1,
},
orderFooter: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: spacing.m,
paddingTop: spacing.m,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
orderTotal: {
color: colors.success,
fontSize: fontSize.lg,
fontWeight: fontWeight.bold,
},
}),
[colors],
);
if (loading && !refreshing)
return <LoadingSpinner message="Chargement historique..." />;
const totalPoints = (stats?.points || 0) + (stats?.points_zipette || 0);
const penaltyCount = penalties?.total_penalty || stats?.penalties || 0;
return (
<View style={styles.container}>
<FlatList
data={orders}
keyExtractor={(item) => String(item.id)}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.accent}
/>
}
contentContainerStyle={styles.listContent}
ListHeaderComponent={
<View>
<View style={styles.statsGrid}>
<View style={[styles.statCard, shadows.sm]}>
<Ionicons
name="receipt-outline"
size={24}
color={colors.accent}
/>
<Text style={styles.statValue}>
{stats?.total_commands || orders.length}
</Text>
<Text style={styles.statLabel}>Commandes</Text>
</View>
<View style={[styles.statCard, shadows.sm]}>
<Ionicons
name="leaf-outline"
size={24}
color={colors.categoryWeedHash}
/>
<Text style={styles.statValue}>
{stats?.points || 0}
</Text>
<Text style={styles.statLabel}>
Pts Weed/Hash
</Text>
</View>
<View style={[styles.statCard, shadows.sm]}>
<Ionicons
name="flash-outline"
size={24}
color={colors.info}
/>
<Text style={styles.statValue}>
{stats?.points_zipette || 0}
</Text>
<Text style={styles.statLabel}>
Pts Zipette
</Text>
</View>
<View style={[styles.statCard, shadows.sm]}>
<Ionicons
name="trophy-outline"
size={24}
color={colors.warning}
/>
<Text style={styles.statValue}>
{totalPoints}
</Text>
<Text style={styles.statLabel}>
Total Points
</Text>
</View>
</View>
{penaltyCount > 0 && (
<View
style={[
styles.penaltyBanner,
penaltyCount >= 3
? styles.penaltyCritical
: styles.penaltyWarning,
]}
>
<Ionicons
name="warning"
size={20}
color={
penaltyCount >= 3
? colors.danger
: colors.warning
}
/>
<Text
style={[
styles.penaltyText,
{
color:
penaltyCount >= 3
? colors.danger
: colors.warning,
},
]}
>
{penaltyCount} penalite
{penaltyCount > 1 ? "s" : ""}
</Text>
</View>
)}
{orders.length > 0 && (
<Text style={styles.sectionTitle}>
Historique des commandes
</Text>
)}
</View>
}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons
name="time-outline"
size={80}
color={colors.textMuted}
/>
<Text style={styles.emptyTitle}>Aucun historique</Text>
<Text style={styles.emptySubtitle}>
Vos commandes terminees apparaitront ici
</Text>
</View>
}
renderItem={({ item: order }) => (
<TouchableOpacity
activeOpacity={0.7}
onPress={() =>
navigation.navigate("OrderDetails", {
orderId: order.id,
})
}
style={[styles.orderCard, shadows.sm]}
>
<View style={styles.orderHeader}>
<Text style={styles.orderIdText}>
Commande #{order.id}
</Text>
<StatusBadge status={order.status} />
</View>
<View style={styles.orderRow}>
<Ionicons
name="location-outline"
size={14}
color={colors.textMuted}
/>
<Text style={styles.orderDetail} numberOfLines={1}>
{order.adresse || "N/A"}
</Text>
</View>
<View style={styles.orderRow}>
<Ionicons
name="time-outline"
size={14}
color={colors.textMuted}
/>
<Text style={styles.orderDetail}>
{formatOrderDate(order.created_at)}
</Text>
</View>
{order.livreur_assign && (
<View style={styles.orderRow}>
<Ionicons
name="bicycle-outline"
size={14}
color={colors.textMuted}
/>
<Text style={styles.orderDetail}>
{order.livreur_assign}
</Text>
</View>
)}
<View style={styles.orderFooter}>
<Text style={styles.orderTotal}>
{formatPrice(order.total_prix || 0)}
</Text>
<Ionicons
name="chevron-forward"
size={18}
color={colors.textMuted}
/>
</View>
</TouchableOpacity>
)}
/>
</View>
);
}