Files
sgw-owner-app/components/manager/ship_components/ShipCard.tsx
2025-12-10 19:54:16 +07:00

523 lines
14 KiB
TypeScript

import { queryShipsImage } from "@/controller/DeviceController";
import { useThemeContext } from "@/hooks/use-theme-context";
import { useGroup } from "@/state/use-group";
import { usePort } from "@/state/use-ports";
import { useShipTypes } from "@/state/use-ship-types";
import { Ionicons } from "@expo/vector-icons";
import { fromByteArray } from "base64-js";
import { useEffect, useState } from "react";
import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native";
interface ShipCardProps {
ship: Model.Ship;
onPress?: () => void;
}
export default function ShipCard({ ship, onPress }: ShipCardProps) {
const { colors } = useThemeContext();
const { ports, getPorts } = usePort();
const { shipTypes, getShipTypes } = useShipTypes();
const [shipImage, setShipImage] = useState<string | null>(null);
const { groups, getUserGroups, getChildrenOfGroups, childrenOfGroups } =
useGroup();
useEffect(() => {
if (ports === null) {
getPorts();
}
}, [ports, getPorts]);
useEffect(() => {
if (!shipTypes || shipTypes.length === 0) {
getShipTypes();
}
}, [shipTypes, getShipTypes]);
useEffect(() => {
if (groups === null) {
getUserGroups();
}
}, [groups, getUserGroups]);
useEffect(() => {
if (groups && ship.ship_group_id) {
const groupId = groups?.groups?.[0]?.id || "";
// childrenOfGroups is initialised as null in the store; check for null to fetch once
if (groupId && childrenOfGroups == null) {
getChildrenOfGroups(groupId);
}
}
}, [groups, childrenOfGroups, getChildrenOfGroups]);
// Themed styles
useEffect(() => {
let mounted = true;
const loadShipImage = async () => {
try {
const resp = await queryShipsImage(ship.id || "");
const contentType = resp.headers["content-type"] || "image/jpeg";
const uint8 = new Uint8Array(resp.data); // ArrayBuffer -> Uint8Array
const base64 = fromByteArray(uint8); // base64-js
const uri = `data:${contentType};base64,${base64}`;
if (!mounted) return;
// assign received value to state if present; adapt to actual resp shape as needed
setShipImage(uri);
} catch (error) {
// console.log("Error when get image: ", error);
}
};
loadShipImage();
return () => {
mounted = false;
};
}, [ship]);
const themedStyles = {
card: {
backgroundColor: colors.card,
shadowColor: colors.text,
},
title: {
color: colors.text,
},
subtitle: {
color: colors.textSecondary,
},
label: {
color: colors.textSecondary,
},
value: {
color: colors.text,
},
divider: {
backgroundColor: colors.separator,
},
badge: {
backgroundColor: colors.primary + "15",
borderColor: colors.primary,
},
badgeText: {
color: colors.primary,
},
infoBox: {
backgroundColor: colors.primary + "10",
},
infoIcon: {
color: colors.primary,
},
};
// ============ IMAGE VARIANT ============
if (shipImage) {
return (
<TouchableOpacity
style={[styles.imageCard, themedStyles.card]}
onPress={onPress}
activeOpacity={0.8}
>
{/* Image Section */}
<View style={styles.imageContainer}>
{shipImage ? (
<Image
source={{ uri: shipImage }}
style={styles.shipImage}
resizeMode="cover"
/>
) : (
<View
style={[
styles.imagePlaceholder,
{ backgroundColor: colors.backgroundSecondary },
]}
>
<Ionicons name="boat" size={48} color={colors.textSecondary} />
</View>
)}
{/* Ship Type Badge */}
{shipTypes && (
<View style={styles.typeBadge}>
<Ionicons name="boat-outline" size={14} color="#fff" />
<Text style={styles.typeBadgeText}>
{shipTypes.find((type) => type.id === ship.ship_type)?.name ||
"Unknown"}
</Text>
</View>
)}
</View>
{/* Info Section */}
<View style={styles.imageCardContent}>
{/* Title & Registration */}
<Text style={[styles.imageCardTitle, themedStyles.title]}>
{ship.name || "Unknown Ship"}
</Text>
<View style={styles.regRow}>
<View style={[styles.regBadge, themedStyles.badge]}>
<Text style={[styles.regBadgeText, themedStyles.badgeText]}>
{ship.reg_number || "-"}
</Text>
</View>
{childrenOfGroups && (
<View style={styles.locationRow}>
<Ionicons
name="location"
size={14}
color={colors.textSecondary}
/>
<Text style={[styles.locationText, themedStyles.subtitle]}>
{childrenOfGroups.groups?.find(
(group) => group?.metadata?.code === ship.province_code
)?.name || "-"}
</Text>
</View>
)}
</View>
{/* Info Grid */}
<View style={styles.imageInfoGrid}>
<InfoBox
icon="resize"
label="Length"
value={ship.ship_length ? `${ship.ship_length}m` : "-"}
themedStyles={themedStyles}
/>
<InfoBox
icon="flash"
label="Engine Power"
value={ship.ship_power ? `${ship.ship_power} HP` : "-"}
themedStyles={themedStyles}
/>
<InfoBox
icon="document-text"
label="License"
value={ship.fishing_license_number || "-"}
themedStyles={themedStyles}
/>
<InfoBox
icon="navigate"
label="Home Port"
value={
ports?.ports
? ports.ports.find((port) => port.id === ship.home_port)
?.name || "-"
: "-"
}
themedStyles={themedStyles}
/>
</View>
</View>
</TouchableOpacity>
);
}
// ============ COMPACT VARIANT ============
return (
<TouchableOpacity
style={[styles.compactCard, themedStyles.card]}
onPress={onPress}
activeOpacity={0.8}
>
{/* Header */}
<View style={styles.compactHeader}>
<View style={[styles.shipIcon, themedStyles.infoBox]}>
<Ionicons name="boat" size={24} color={colors.primary} />
</View>
<View style={styles.compactHeaderText}>
<Text style={[styles.compactTitle, themedStyles.title]}>
{ship.name || "Unknown Ship"}
</Text>
<Text style={[styles.compactSubtitle, themedStyles.subtitle]}>
{shipTypes.find((type) => type.id === ship.ship_type)?.name ||
"Unknown"}
</Text>
</View>
<View style={[styles.regBadge, themedStyles.badge]}>
<Text style={[styles.regBadgeText, themedStyles.badgeText]}>
{ship.reg_number || "-"}
</Text>
</View>
</View>
{/* Info Grid */}
<View style={styles.compactInfoGrid}>
<CompactInfoBox
icon="resize"
label="Length"
value={ship.ship_length ? `${ship.ship_length}m` : "-"}
themedStyles={themedStyles}
/>
<CompactInfoBox
icon="flash"
label="Power"
value={ship.ship_power ? `${ship.ship_power} HP` : "-"}
themedStyles={themedStyles}
/>
<CompactInfoBox
icon="navigate"
label="Port"
value={
ports?.ports
? ports.ports.find((port) => port.id === ship.home_port)?.name ||
"-"
: "-"
}
themedStyles={themedStyles}
/>
<CompactInfoBox
icon="document-text"
label="License"
value={ship.fishing_license_number || "-"}
themedStyles={themedStyles}
/>
<CompactInfoBox
icon="location"
label="Province"
value={
childrenOfGroups?.groups?.find(
(group) => group?.metadata?.code === ship.province_code
)?.name || "-"
}
themedStyles={themedStyles}
/>
</View>
{/* Footer - IMO & MMSI */}
{(ship.imo_number || ship.mmsi_number) && (
<>
<View style={[styles.divider, themedStyles.divider]} />
<View style={styles.footerInfo}>
{ship.imo_number && (
<View style={styles.footerRow}>
<Text style={[styles.footerLabel, themedStyles.label]}>
IMO Number:
</Text>
<Text style={[styles.footerValue, themedStyles.value]}>
{ship.imo_number}
</Text>
</View>
)}
{ship.mmsi_number && (
<View style={styles.footerRow}>
<Text style={[styles.footerLabel, themedStyles.label]}>
MMSI Number:
</Text>
<Text style={[styles.footerValue, themedStyles.value]}>
{ship.mmsi_number}
</Text>
</View>
)}
</View>
</>
)}
</TouchableOpacity>
);
}
// ============ SUB-COMPONENTS ============
interface InfoBoxProps {
icon: keyof typeof Ionicons.glyphMap;
label: string;
value: string;
themedStyles: any;
}
function InfoBox({ icon, label, value, themedStyles }: InfoBoxProps) {
return (
<View style={styles.infoBox}>
<Ionicons name={icon} size={18} color={themedStyles.infoIcon.color} />
<Text style={[styles.infoLabel, themedStyles.label]}>{label}</Text>
<Text style={[styles.infoValue, themedStyles.value]}>{value}</Text>
</View>
);
}
function CompactInfoBox({ icon, label, value, themedStyles }: InfoBoxProps) {
return (
<View style={[styles.compactInfoBox, themedStyles.infoBox]}>
<Ionicons name={icon} size={16} color={themedStyles.infoIcon.color} />
<View style={styles.compactInfoText}>
<Text style={[styles.compactInfoLabel, themedStyles.label]}>
{label}
</Text>
<Text style={[styles.compactInfoValue, themedStyles.value]}>
{value}
</Text>
</View>
</View>
);
}
// ============ STYLES ============
const styles = StyleSheet.create({
// === IMAGE VARIANT ===
imageCard: {
borderRadius: 16,
overflow: "hidden",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
marginVertical: 8,
marginHorizontal: 16,
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
},
imageContainer: {
height: 180,
position: "relative",
},
shipImage: {
width: "100%",
height: "100%",
},
imagePlaceholder: {
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
},
typeBadge: {
position: "absolute",
top: 12,
left: 12,
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(59, 130, 246, 0.9)",
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 8,
gap: 6,
},
typeBadgeText: {
color: "#fff",
fontSize: 12,
fontWeight: "600",
},
imageCardContent: {
padding: 16,
},
imageCardTitle: {
fontSize: 18,
fontWeight: "700",
marginBottom: 8,
},
regRow: {
flexDirection: "row",
alignItems: "center",
gap: 12,
marginBottom: 16,
},
regBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 6,
borderWidth: 1,
},
regBadgeText: {
fontSize: 12,
fontWeight: "600",
},
locationRow: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
locationText: {
fontSize: 13,
},
imageInfoGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: 12,
},
infoBox: {
width: "47%",
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingVertical: 8,
},
infoLabel: {
fontSize: 12,
},
infoValue: {
fontSize: 14,
fontWeight: "600",
marginLeft: "auto",
},
// === COMPACT VARIANT ===
compactCard: {
borderRadius: 16,
padding: 16,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 2,
marginVertical: 8,
marginHorizontal: 16,
},
compactHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: 16,
},
shipIcon: {
width: 48,
height: 48,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
compactHeaderText: {
flex: 1,
marginLeft: 12,
},
compactTitle: {
fontSize: 16,
fontWeight: "700",
},
compactSubtitle: {
fontSize: 13,
marginTop: 2,
},
compactInfoGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: 10,
},
compactInfoBox: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 10,
gap: 8,
minWidth: "47%",
flexGrow: 1,
},
compactInfoText: {
flex: 1,
},
compactInfoLabel: {
fontSize: 11,
},
compactInfoValue: {
fontSize: 14,
fontWeight: "600",
},
divider: {
height: 1,
marginVertical: 12,
},
footerInfo: {
gap: 6,
},
footerRow: {
flexDirection: "row",
justifyContent: "space-between",
},
footerLabel: {
fontSize: 13,
},
footerValue: {
fontSize: 13,
fontWeight: "500",
},
});