hiển thị thuyền thông tin tàu

This commit is contained in:
Tran Anh Tuan
2025-12-03 16:22:25 +07:00
parent 47e9bac0f9
commit 22a3b591c6
22 changed files with 2135 additions and 260 deletions

View File

@@ -0,0 +1,253 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useEffect, useState } from "react";
import {
Pressable,
StyleSheet,
Text,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
interface DraggablePanelProps {
minHeightPct?: number;
maxHeightPct?: number;
initialState?: "min" | "max";
onExpandedChange?: (expanded: boolean) => void;
children?: React.ReactNode;
}
export default function DraggablePanel({
minHeightPct = 0.1,
maxHeightPct = 0.6,
initialState = "min",
onExpandedChange,
children,
}: DraggablePanelProps) {
const { height: screenHeight } = useWindowDimensions();
// Thêm chiều cao của bottom tab bar vào tính toán
const bottomOffset = 80; // 50 là chiều cao mặc định của tab bar
const minHeight = screenHeight * minHeightPct;
const maxHeight = screenHeight * maxHeightPct;
// State để quản lý icon
const [iconName, setIconName] = useState<"chevron-down" | "chevron-up">(
initialState === "max" ? "chevron-down" : "chevron-up"
);
// Sử dụng translateY để điều khiển vị trí
const translateY = useSharedValue(
initialState === "min"
? screenHeight - minHeight - bottomOffset
: screenHeight - maxHeight - bottomOffset
);
const isExpanded = useSharedValue(initialState === "max");
// Update khi screen height thay đổi (xoay màn hình)
useEffect(() => {
const currentHeight = isExpanded.value ? maxHeight : minHeight;
translateY.value = screenHeight - currentHeight - bottomOffset;
}, [screenHeight, minHeight, maxHeight, bottomOffset]);
const notifyExpandedChange = (expanded: boolean) => {
if (onExpandedChange) {
onExpandedChange(expanded);
}
};
const togglePanel = () => {
const newExpanded = !isExpanded.value;
isExpanded.value = newExpanded;
const targetY = newExpanded
? screenHeight - maxHeight - bottomOffset
: screenHeight - minHeight - bottomOffset;
translateY.value = withTiming(targetY, {
duration: 500,
});
notifyExpandedChange(newExpanded);
};
const startY = useSharedValue(0);
const panGesture = Gesture.Pan()
.onStart(() => {
startY.value = translateY.value;
})
.onUpdate((event) => {
// Cập nhật translateY theo gesture
const newY = startY.value + event.translationY;
// Clamp giá trị trong khoảng [screenHeight - maxHeight - bottomOffset, screenHeight - minHeight - bottomOffset]
const minY = screenHeight - maxHeight - bottomOffset;
const maxY = screenHeight - minHeight - bottomOffset;
if (newY >= minY && newY <= maxY) {
translateY.value = newY;
} else if (newY < minY) {
translateY.value = minY;
} else if (newY > maxY) {
translateY.value = maxY;
}
})
.onEnd((event) => {
// Tính toán vị trí để snap
const currentHeight = screenHeight - translateY.value - bottomOffset;
const midHeight = (minHeight + maxHeight) / 2;
// Kiểm tra velocity để quyết định snap
const snapToMax = event.velocityY < -100 || currentHeight > midHeight;
const targetY = snapToMax
? screenHeight - maxHeight - bottomOffset + 40
: screenHeight - minHeight - bottomOffset;
translateY.value = withSpring(
targetY,
{
damping: 20,
stiffness: 200,
},
() => {
"worklet";
isExpanded.value = snapToMax;
}
);
});
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateY: translateY.value }],
};
});
// Sử dụng useAnimatedReaction để cập nhật icon dựa trên chiều cao
useAnimatedReaction(
() => {
const currentHeight = screenHeight - translateY.value - bottomOffset;
return currentHeight > minHeight + 10;
},
(isCurrentlyExpanded) => {
const newIcon = isCurrentlyExpanded ? "chevron-down" : "chevron-up";
runOnJS(setIconName)(newIcon);
}
);
return (
<Animated.View style={[styles.panelContainer, animatedStyle]}>
<View style={[styles.panel, { height: maxHeight }]}>
{/* Header với drag handle và nút toggle */}
<GestureDetector gesture={panGesture}>
<Pressable onPress={togglePanel} style={styles.header}>
<View style={styles.dragHandle} />
<TouchableOpacity
onPress={togglePanel}
style={styles.toggleButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name={iconName} size={24} color="#666" />
</TouchableOpacity>
</Pressable>
</GestureDetector>
{/* Nội dung */}
<View style={styles.content}>
{children || (
<View style={styles.placeholderContent}>
<Text style={styles.placeholderText}>Draggable Panel</Text>
<Text style={styles.placeholderSubtext}>
Click hoặc kéo đ mở rộng panel này
</Text>
<Text style={styles.placeholderSubtext}>
Min: {(minHeightPct * 100).toFixed(0)}% | Max:{" "}
{(maxHeightPct * 100).toFixed(0)}%
</Text>
</View>
)}
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
panelContainer: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "100%",
pointerEvents: "box-none",
},
panel: {
position: "absolute",
top: 0,
left: 0,
right: 0,
backgroundColor: "white",
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: -2,
},
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 10,
},
header: {
paddingTop: 12,
paddingBottom: 8,
paddingHorizontal: 16,
alignItems: "center",
justifyContent: "center",
},
dragHandle: {
width: 40,
height: 4,
backgroundColor: "#D1D5DB",
borderRadius: 2,
marginBottom: 8,
},
toggleButton: {
position: "absolute",
right: 16,
top: 1,
padding: 4,
},
content: {
flex: 1,
paddingHorizontal: 16,
// paddingBottom: 16,
},
placeholderContent: {
alignItems: "center",
paddingTop: 20,
},
placeholderText: {
fontSize: 18,
fontWeight: "600",
color: "#333",
marginBottom: 8,
},
placeholderSubtext: {
fontSize: 14,
color: "#666",
textAlign: "center",
marginTop: 4,
},
});

View File

@@ -0,0 +1,522 @@
import { Colors } from "@/config";
import { queryShipGroups } from "@/controller/DeviceController";
import { ColorScheme, useTheme } from "@/hooks/use-theme-context";
import { useShipTypes } from "@/state/use-ship-types";
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import {
Animated,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import Select from "./Select";
import Slider from "./Slider";
interface ShipSearchFormProps {
initialValues?: Partial<SearchShipResponse>;
isOpen: boolean;
onClose: () => void;
onSubmit?: (data: SearchShipResponse) => void;
}
export interface SearchShipResponse {
ship_name: string;
ship_length: [number, number];
reg_number: string;
ship_power: [number, number];
ship_type: string | number;
alarm_list: string;
ship_group_id: string;
group_id: string;
}
const ShipSearchForm = (props: ShipSearchFormProps) => {
const { colors, colorScheme } = useTheme();
const styles = useMemo(
() => createStyles(colors, colorScheme),
[colors, colorScheme]
);
const { shipTypes, getShipTypes } = useShipTypes();
const [groupShips, setGroupShips] = useState<Model.ShipGroup[]>([]);
const [slideAnim] = useState(new Animated.Value(0));
const { control, handleSubmit, reset, watch } = useForm<SearchShipResponse>({
defaultValues: {
ship_name: props.initialValues?.ship_name || "",
reg_number: props.initialValues?.reg_number || "",
ship_length: [0, 100],
ship_power: [0, 100000],
ship_type: props.initialValues?.ship_type || "",
alarm_list: props.initialValues?.alarm_list || "",
ship_group_id: props.initialValues?.ship_group_id || "",
group_id: props.initialValues?.group_id || "",
},
});
const shipLengthValue = watch("ship_length");
const shipPowerValue = watch("ship_power");
useEffect(() => {
if (shipTypes.length === 0) {
getShipTypes();
}
}, [shipTypes]);
useEffect(() => {
getShipGroups();
}, []);
useEffect(() => {
if (props.isOpen) {
Animated.spring(slideAnim, {
toValue: 1,
useNativeDriver: true,
tension: 50,
friction: 8,
}).start();
} else {
slideAnim.setValue(0);
}
}, [props.isOpen]);
useEffect(() => {
if (props.initialValues) {
reset({
ship_name: props.initialValues.ship_name || "",
reg_number: props.initialValues.reg_number || "",
ship_length: [0, 100],
ship_power: [0, 100000],
ship_type: props.initialValues.ship_type || "",
alarm_list: props.initialValues.alarm_list || "",
ship_group_id: props.initialValues.ship_group_id || "",
group_id: props.initialValues.group_id || "",
});
}
}, [props.initialValues]);
const getShipGroups = async () => {
try {
const response = await queryShipGroups();
if (response && response.data) {
setGroupShips(response.data);
}
} catch (error) {
console.error("Error fetching ship groups:", error);
}
};
const alarmListLabel = [
{
label: "Tiếp cận vùng hạn chế",
value: "50:10",
},
{
label: "Đã ra (vào) vùng hạn chế)",
value: "50:11",
},
{
label: "Đang đánh bắt trong vùng hạn chế",
value: "50:12",
},
];
const onSubmitForm = (data: SearchShipResponse) => {
props.onSubmit?.(data);
props.onClose();
};
const onReset = () => {
reset({
ship_name: "",
reg_number: "",
ship_length: [0, 100],
ship_power: [0, 100000],
ship_type: "",
alarm_list: "",
ship_group_id: "",
group_id: "",
});
};
const translateY = slideAnim.interpolate({
inputRange: [0, 1],
outputRange: [600, 0],
});
return (
<Modal
animationType="fade"
transparent={true}
visible={props.isOpen}
onRequestClose={props.onClose}
>
<GestureHandlerRootView style={{ flex: 1 }}>
<Pressable style={styles.backdrop} onPress={props.onClose}>
<Animated.View
style={[styles.modalContent, { transform: [{ translateY }] }]}
onStartShouldSetResponder={() => true}
>
{/* Header */}
<View style={styles.header}>
<View style={styles.dragIndicator} />
<Text style={[styles.headerTitle, { color: colors.text }]}>
Tìm kiếm tàu
</Text>
<TouchableOpacity
onPress={props.onClose}
style={styles.closeButton}
>
<Text style={[styles.closeButtonText, { color: colors.text }]}>
</Text>
</TouchableOpacity>
</View>
{/* Form Content */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.formSection}>
{/* Tên tàu */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.text }]}>
Tên tàu
</Text>
<Controller
control={control}
name="ship_name"
render={({ field: { onChange, value } }) => (
<TextInput
placeholder="Hoàng Sa 001"
placeholderTextColor={colors.textSecondary}
style={[
styles.input,
{
borderColor: colors.border,
backgroundColor: colors.surface,
color: colors.text,
},
]}
value={value}
onChangeText={onChange}
/>
)}
/>
</View>
{/* Số đăng ký */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.text }]}>
Số đăng
</Text>
<Controller
control={control}
name="reg_number"
render={({ field: { onChange, value } }) => (
<TextInput
placeholder="VN-00001"
placeholderTextColor={colors.textSecondary}
style={[
styles.input,
{
borderColor: colors.border,
backgroundColor: colors.surface,
color: colors.text,
},
]}
value={value}
onChangeText={onChange}
/>
)}
/>
</View>
{/* Chiều dài */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.text }]}>
Chiều dài ({shipLengthValue[0]}m - {shipLengthValue[1]}m)
</Text>
<Controller
control={control}
name="ship_length"
render={({ field: { onChange, value } }) => (
<View style={styles.sliderContainer}>
<Slider
range={true}
min={0}
max={100}
step={1}
value={value}
marks={{
0: "0m",
50: "50m",
100: "100m",
}}
onValueChange={(val) =>
onChange(val as [number, number])
}
activeColor={colors.primary}
/>
</View>
)}
/>
</View>
{/* Công suất */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.text }]}>
Công suất ({shipPowerValue[0]}kW - {shipPowerValue[1]}kW)
</Text>
<Controller
control={control}
name="ship_power"
render={({ field: { onChange, value } }) => (
<View style={styles.sliderContainer}>
<Slider
range={true}
min={0}
max={100000}
step={1000}
value={value}
marks={{
0: "0kW",
50000: "50,000kW",
100000: "100,000kW",
}}
onValueChange={(val) =>
onChange(val as [number, number])
}
activeColor={colors.primary}
/>
</View>
)}
/>
</View>
{/* Loại tàu */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.text }]}>
Loại tàu
</Text>
<Controller
control={control}
name="ship_type"
render={({ field: { onChange, value } }) => (
<Select
options={shipTypes.map((type) => ({
label: type.name || "",
value: type.id || 0,
}))}
placeholder="Chọn loại tàu"
value={value}
onChange={onChange}
/>
)}
/>
</View>
{/* Cảnh báo */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.text }]}>
Cảnh báo
</Text>
<Controller
control={control}
name="alarm_list"
render={({ field: { onChange, value } }) => (
<Select
options={alarmListLabel.map((type) => ({
label: type.label || "",
value: type.value || "",
}))}
placeholder="Chọn loại cảnh báo"
value={value}
onChange={onChange}
/>
)}
/>
</View>
{/* Đội tàu */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.text }]}>
Đi tàu
</Text>
<Controller
control={control}
name="ship_group_id"
render={({ field: { onChange, value } }) => (
<Select
options={groupShips.map((group) => ({
label: group.name || "",
value: group.id || "",
}))}
placeholder="Chọn đội tàu"
value={value}
onChange={onChange}
/>
)}
/>
</View>
</View>
</ScrollView>
{/* Action Buttons */}
<View
style={[styles.actionButtons, { borderTopColor: colors.border }]}
>
<TouchableOpacity
style={[styles.resetButton, { borderColor: colors.border }]}
onPress={onReset}
>
<Text style={[styles.resetButtonText, { color: colors.text }]}>
Đt lại
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
{ backgroundColor: colors.primary },
]}
onPress={handleSubmit(onSubmitForm)}
>
<Text style={styles.submitButtonText}>Tìm kiếm</Text>
</TouchableOpacity>
</View>
</Animated.View>
</Pressable>
</GestureHandlerRootView>
</Modal>
);
};
const createStyles = (colors: typeof Colors.light, scheme: ColorScheme) =>
StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalContent: {
height: "85%",
backgroundColor: colors.background,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: -4,
},
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 10,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: 16,
paddingHorizontal: 20,
borderBottomWidth: 1,
borderBottomColor: colors.border,
position: "relative",
},
dragIndicator: {
position: "absolute",
top: 8,
width: 40,
height: 4,
backgroundColor: colors.border,
borderRadius: 2,
},
headerTitle: {
fontSize: 18,
fontWeight: "700",
textAlign: "center",
},
closeButton: {
position: "absolute",
right: 16,
top: 16,
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: 16,
},
closeButtonText: {
fontSize: 20,
fontWeight: "300",
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 20,
},
formSection: {
paddingHorizontal: 20,
paddingTop: 20,
},
fieldGroup: {
marginBottom: 24,
},
label: {
fontSize: 15,
fontWeight: "600",
marginBottom: 10,
},
input: {
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 15,
},
sliderContainer: {
paddingHorizontal: 4,
paddingTop: 8,
},
actionButtons: {
flexDirection: "row",
paddingHorizontal: 20,
paddingVertical: 16,
gap: 12,
borderTopWidth: 1,
},
resetButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
borderWidth: 1,
alignItems: "center",
justifyContent: "center",
},
resetButtonText: {
fontSize: 16,
fontWeight: "600",
},
submitButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
submitButtonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "600",
},
});
export default ShipSearchForm;

346
components/Slider.tsx Normal file
View File

@@ -0,0 +1,346 @@
import { useEffect, useState } from "react";
import { LayoutChangeEvent, StyleSheet, Text, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
interface SliderProps {
min?: number;
max?: number;
step?: number;
range?: boolean;
value?: number | [number, number];
onValueChange?: (value: any) => void;
onSlidingComplete?: (value: any) => void;
disabled?: boolean;
trackHeight?: number;
thumbSize?: number;
activeColor?: string;
inactiveColor?: string;
marks?: Record<number, string>;
style?: any;
}
export default function Slider({
min = 0,
max = 100,
step = 1,
range = false,
value = 0,
onValueChange,
onSlidingComplete,
disabled = false,
trackHeight = 4,
thumbSize = 20,
activeColor = "#1677ff", // Ant Design blue
inactiveColor = "#e5e7eb", // Gray-200
marks,
style,
}: SliderProps) {
const [width, setWidth] = useState(0);
// Shared values for positions
const translateX1 = useSharedValue(0); // Left thumb (or 0 for single)
const translateX2 = useSharedValue(0); // Right thumb (or value for single)
const isDragging1 = useSharedValue(false);
const isDragging2 = useSharedValue(false);
const scale1 = useSharedValue(1);
const scale2 = useSharedValue(1);
const context1 = useSharedValue(0);
const context2 = useSharedValue(0);
// Calculate position from value
const getPositionFromValue = (val: number) => {
"worklet";
if (width === 0) return 0;
const clampedVal = Math.min(Math.max(val, min), max);
return ((clampedVal - min) / (max - min)) * width;
};
// Calculate value from position
const getValueFromPosition = (pos: number) => {
"worklet";
if (width === 0) return min;
const percentage = Math.min(Math.max(pos, 0), width) / width;
const rawValue = min + percentage * (max - min);
// Snap to step
const steppedValue = Math.round(rawValue / step) * step;
return Math.min(Math.max(steppedValue, min), max);
};
// Update positions when props change
useEffect(() => {
if (width === 0) return;
if (range) {
const vals = Array.isArray(value) ? value : [min, value as number];
const v1 = Math.min(vals[0], vals[1]);
const v2 = Math.max(vals[0], vals[1]);
if (!isDragging1.value) {
translateX1.value = withTiming(getPositionFromValue(v1), {
duration: 200,
});
}
if (!isDragging2.value) {
translateX2.value = withTiming(getPositionFromValue(v2), {
duration: 200,
});
}
} else {
const val = typeof value === "number" ? value : value[0];
if (!isDragging2.value) {
translateX2.value = withTiming(getPositionFromValue(val), {
duration: 200,
});
}
translateX1.value = 0; // Always 0 for single slider track start
}
}, [value, width, min, max, range]);
// Thumb 1 Gesture (Only for range)
const thumb1Gesture = Gesture.Pan()
.enabled(!disabled && range)
.onStart(() => {
context1.value = translateX1.value;
isDragging1.value = true;
scale1.value = withSpring(1.2);
})
.onUpdate((event) => {
if (width === 0) return;
let newPos = context1.value + event.translationX;
// Constrain: 0 <= newPos <= translateX2
const maxPos = translateX2.value;
newPos = Math.min(Math.max(newPos, 0), maxPos);
translateX1.value = newPos;
if (onValueChange) {
const v1 = getValueFromPosition(newPos);
const v2 = getValueFromPosition(translateX2.value);
runOnJS(onValueChange)([v1, v2]);
}
})
.onEnd(() => {
isDragging1.value = false;
scale1.value = withSpring(1);
if (onSlidingComplete) {
const v1 = getValueFromPosition(translateX1.value);
const v2 = getValueFromPosition(translateX2.value);
runOnJS(onSlidingComplete)([v1, v2]);
}
});
// Thumb 2 Gesture (Main thumb for single, Right thumb for range)
const thumb2Gesture = Gesture.Pan()
.enabled(!disabled)
.onStart(() => {
context2.value = translateX2.value;
isDragging2.value = true;
scale2.value = withSpring(1.2);
})
.onUpdate((event) => {
if (width === 0) return;
let newPos = context2.value + event.translationX;
// Constrain: translateX1 <= newPos <= width
const minPos = range ? translateX1.value : 0;
newPos = Math.min(Math.max(newPos, minPos), width);
translateX2.value = newPos;
if (onValueChange) {
const v2 = getValueFromPosition(newPos);
if (range) {
const v1 = getValueFromPosition(translateX1.value);
runOnJS(onValueChange)([v1, v2]);
} else {
runOnJS(onValueChange)(v2);
}
}
})
.onEnd(() => {
isDragging2.value = false;
scale2.value = withSpring(1);
if (onSlidingComplete) {
const v2 = getValueFromPosition(translateX2.value);
if (range) {
const v1 = getValueFromPosition(translateX1.value);
runOnJS(onSlidingComplete)([v1, v2]);
} else {
runOnJS(onSlidingComplete)(v2);
}
}
});
const trackStyle = useAnimatedStyle(() => {
return {
left: range ? translateX1.value : 0,
width: range ? translateX2.value - translateX1.value : translateX2.value,
};
});
const thumb1Style = useAnimatedStyle(() => {
return {
transform: [
{ translateX: translateX1.value - thumbSize / 2 },
{ scale: scale1.value },
],
zIndex: isDragging1.value ? 10 : 1,
};
});
const thumb2Style = useAnimatedStyle(() => {
return {
transform: [
{ translateX: translateX2.value - thumbSize / 2 },
{ scale: scale2.value },
],
zIndex: isDragging2.value ? 10 : 1,
};
});
const onLayout = (event: LayoutChangeEvent) => {
setWidth(event.nativeEvent.layout.width);
};
const containerHeight = Math.max(trackHeight, thumbSize) + (marks ? 30 : 0);
const marksTop = Math.max(trackHeight, thumbSize) + 10;
return (
<View style={[styles.container, style, { height: containerHeight }]}>
<View
style={[
styles.track,
{
height: trackHeight,
backgroundColor: inactiveColor,
borderRadius: trackHeight / 2,
},
]}
onLayout={onLayout}
>
<Animated.View
style={[
styles.activeTrack,
trackStyle,
{
height: trackHeight,
backgroundColor: activeColor,
borderRadius: trackHeight / 2,
},
]}
/>
</View>
{/* Thumb 1 - Only render if range is true */}
{range && (
<GestureDetector gesture={thumb1Gesture}>
<Animated.View
style={[
styles.thumb,
thumb1Style,
{
width: thumbSize,
height: thumbSize,
borderRadius: thumbSize / 2,
backgroundColor: "white",
borderColor: activeColor,
borderWidth: 2,
},
disabled && styles.disabledThumb,
]}
/>
</GestureDetector>
)}
{/* Thumb 2 - Always render */}
<GestureDetector gesture={thumb2Gesture}>
<Animated.View
style={[
styles.thumb,
thumb2Style,
{
width: thumbSize,
height: thumbSize,
borderRadius: thumbSize / 2,
backgroundColor: "white",
borderColor: activeColor,
borderWidth: 2,
},
disabled && styles.disabledThumb,
]}
/>
</GestureDetector>
{/* Marks */}
{marks && (
<View
style={{
position: "absolute",
top: marksTop,
width: "100%",
height: 20,
}}
>
{Object.entries(marks).map(([key, label]) => {
const val = Number(key);
const pos = getPositionFromValue(val);
return (
<Text
key={key}
style={{
position: "absolute",
left: pos - 10,
fontSize: 12,
color: "#666",
textAlign: "center",
width: "auto",
marginTop: 5,
}}
>
{label}
</Text>
);
})}
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
justifyContent: "center",
width: "100%",
},
track: {
width: "100%",
position: "absolute",
overflow: "hidden",
},
activeTrack: {
position: "absolute",
left: 0,
top: 0,
},
thumb: {
position: "absolute",
left: 0,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
disabledThumb: {
borderColor: "#ccc",
},
});

View File

@@ -1,6 +1,6 @@
import { ThemedText } from "@/components/themed-text";
import { useAppTheme } from "@/hooks/use-app-theme";
import { View } from "react-native";
import { ScrollView, View } from "react-native";
interface DescriptionProps {
title?: string;
@@ -13,12 +13,14 @@ export const Description = ({
const { colors } = useAppTheme();
return (
<View className="flex-row gap-2 ">
<ThemedText
style={{ color: colors.textSecondary, fontSize: 16 }}
>
<ThemedText style={{ color: colors.textSecondary, fontSize: 16 }}>
{title}:
</ThemedText>
<ThemedText style={{ fontSize: 16 }}>{description}</ThemedText>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<ThemedText style={{ color: colors.textSecondary, fontSize: 16 }}>
{description || "-"}
</ThemedText>
</ScrollView>
</View>
);
};

141
components/map/ShipInfo.tsx Normal file
View File

@@ -0,0 +1,141 @@
import { STATUS_DANGEROUS, STATUS_NORMAL, STATUS_WARNING } from "@/constants";
import { useAppTheme } from "@/hooks/use-app-theme";
import { useI18n } from "@/hooks/use-i18n";
import { formatRelativeTime } from "@/services/time_service";
import { convertToDMS, kmhToKnot } from "@/utils/geom";
import { Ionicons } from "@expo/vector-icons";
import { ScrollView, Text, View } from "react-native";
import { ThemedText } from "../themed-text";
interface ShipInfoProps {
thingMetadata?: Model.ThingMetadata;
}
const ShipInfo = ({ thingMetadata }: ShipInfoProps) => {
const { t } = useI18n();
const { colors } = useAppTheme();
// Định nghĩa màu sắc theo trạng thái
const statusConfig = {
normal: {
dotColor: "bg-green-500",
badgeColor: "bg-green-100",
badgeTextColor: "text-green-700",
badgeText: "Bình thường",
},
warning: {
dotColor: "bg-yellow-500",
badgeColor: "bg-yellow-100",
badgeTextColor: "text-yellow-700",
badgeText: "Cảnh báo",
},
danger: {
dotColor: "bg-red-500",
badgeColor: "bg-red-100",
badgeTextColor: "text-red-700",
badgeText: "Nguy hiểm",
},
};
const getThingStatus = () => {
switch (thingMetadata?.state_level) {
case STATUS_NORMAL:
return "normal";
case STATUS_WARNING:
return "warning";
case STATUS_DANGEROUS:
return "danger";
default:
return "normal";
}
};
const gpsData: Model.GPSResponse = JSON.parse(thingMetadata?.gps || "{}");
const currentStatus = statusConfig[getThingStatus()];
// Format tọa độ
const formatCoordinate = (lat: number, lon: number) => {
const latDir = lat >= 0 ? "N" : "S";
const lonDir = lon >= 0 ? "E" : "W";
return `${Math.abs(lat).toFixed(4)}°${latDir}, ${Math.abs(lon).toFixed(
4
)}°${lonDir}`;
};
return (
<View className="px-4 py-3">
{/* Header: Tên tàu và trạng thái */}
<View className="flex-row items-center justify-between mb-3">
<View className="flex-row items-center gap-2">
{/* Status dot */}
<View className={`h-3 w-3 rounded-full ${currentStatus.dotColor}`} />
{/* Tên tàu */}
<Text className="text-lg font-semibold text-gray-900">
{thingMetadata?.ship_name}
</Text>
</View>
{/* Badge trạng thái */}
<View className={`px-3 py-1 rounded-full ${currentStatus.badgeColor}`}>
<Text
className={`text-md font-medium ${currentStatus.badgeTextColor}`}
>
{currentStatus.badgeText}
</Text>
</View>
</View>
{/* Mã tàu */}
{/* <Text className="text-base text-gray-600 mb-2">{shipCode}</Text> */}
<View className="flex-row items-center justify-between gap-2 mb-3">
<View className="flex-row items-center gap-2 w-2/3">
<Ionicons name="speedometer-outline" size={16} color="#6B7280" />
<Text className="text-md text-gray-600">
{kmhToKnot(gpsData.s || 0)} {t("home.speed_units")}
</Text>
</View>
<View className="flex-row items-start gap-2 w-1/3 ">
<Ionicons name="compass-outline" size={16} color="#6B7280" />
<Text className="text-md text-gray-600">{gpsData.h}°</Text>
</View>
</View>
{/* Tọa độ */}
<View className="flex-row items-center justify-between gap-2 mb-2">
<View className="flex-row items-center gap-2 w-2/3">
<Ionicons name="location-outline" size={16} color="#6B7280" />
<Text className="text-md text-gray-600">
{convertToDMS(gpsData.lat || 0, true)},
{convertToDMS(gpsData.lon || 0, false)}
</Text>
</View>
<View className=" flex-row items-start gap-2 w-1/3 ">
<Ionicons name="time-outline" size={16} color="#6B7280" />
<Text className="text-md text-gray-600">
{formatRelativeTime(gpsData.t)}
</Text>
</View>
</View>
{/* <View className="flex">
<Description title="Trạng thái" description={thingMetadata?.state} />
</View> */}
{thingMetadata?.state !== "" && (
<View className="flex-row items-center gap-2">
<ThemedText style={{ color: colors.textSecondary, fontSize: 15 }}>
Trạng thái:
</ThemedText>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ flex: 1 }}
>
<ThemedText style={{ color: colors.text, fontSize: 15 }}>
{thingMetadata?.state || "-"}
</ThemedText>
</ScrollView>
</View>
)}
</View>
);
};
export default ShipInfo;

165
components/map/TagState.tsx Normal file
View File

@@ -0,0 +1,165 @@
import { useState } from "react";
import { Pressable, ScrollView, StyleSheet, View } from "react-native";
import { ThemedText } from "../themed-text";
export type TagStateCallbackPayload = {
isNormal: boolean;
isWarning: boolean;
isDangerous: boolean;
isSos: boolean;
isDisconected: boolean; // giữ đúng theo yêu cầu (1 'n')
};
export type TagStateProps = {
normalCount?: number;
warningCount?: number;
dangerousCount?: number;
sosCount?: number;
disconnectedCount?: number;
onTagPress?: (selection: TagStateCallbackPayload) => void;
className?: string;
};
export function TagState({
normalCount = 0,
warningCount = 0,
dangerousCount = 0,
sosCount = 0,
disconnectedCount = 0,
onTagPress,
className = "",
}: TagStateProps) {
// Quản lý trạng thái các tag ở cấp cha để trả về tổng hợp
const [activeStates, setActiveStates] = useState({
normal: false,
warning: false,
dangerous: false,
sos: false,
disconnected: false,
});
const toggleState = (state: keyof typeof activeStates) => {
setActiveStates((prev) => {
const next = { ...prev, [state]: !prev[state] };
// Gọi callback với object tổng hợp sau khi cập nhật
onTagPress?.({
isNormal: next.normal,
isWarning: next.warning,
isDangerous: next.dangerous,
isSos: next.sos,
isDisconected: next.disconnected,
});
return next;
});
};
const renderTag = (
state: "normal" | "warning" | "dangerous" | "sos" | "disconnected",
count: number,
label: string,
colors: {
defaultBg: string;
defaultBorder: string;
defaultText: string;
activeBg: string;
activeBorder: string;
activeText: string;
}
) => {
if (count === 0) return null;
const pressed = activeStates[state];
return (
<Pressable
key={state}
onPress={() => toggleState(state)}
style={[
styles.tag,
{
backgroundColor: pressed ? colors.activeBg : colors.defaultBg,
borderColor: pressed ? colors.activeBorder : colors.defaultBorder,
},
]}
>
<ThemedText
style={[
styles.tagText,
{
color: pressed ? colors.activeText : colors.defaultText,
},
]}
type="defaultSemiBold"
>
{label}: {count}
</ThemedText>
</Pressable>
);
};
return (
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className={className} style={styles.container}>
{renderTag("normal", normalCount, "Bình thường", {
defaultBg: "#FFFFFF",
defaultBorder: "#22C55E",
defaultText: "#22C55E",
activeBg: "#22C55E",
activeBorder: "#22C55E",
activeText: "#FFFFFF",
})}
{renderTag("warning", warningCount, "Cảnh báo", {
defaultBg: "#FFFFFF",
defaultBorder: "#EAB308",
defaultText: "#EAB308",
activeBg: "#EAB308",
activeBorder: "#EAB308",
activeText: "#FFFFFF",
})}
{renderTag("dangerous", dangerousCount, "Nguy hiểm", {
defaultBg: "#FFFFFF",
defaultBorder: "#F97316",
defaultText: "#F97316",
activeBg: "#F97316",
activeBorder: "#F97316",
activeText: "#FFFFFF",
})}
{renderTag("sos", sosCount, "SOS", {
defaultBg: "#FFFFFF",
defaultBorder: "#EF4444",
defaultText: "#EF4444",
activeBg: "#EF4444",
activeBorder: "#EF4444",
activeText: "#FFFFFF",
})}
{renderTag("disconnected", disconnectedCount, "Mất kết nối", {
defaultBg: "#FFFFFF",
defaultBorder: "#6B7280",
defaultText: "#6B7280",
activeBg: "#6B7280",
activeBorder: "#6B7280",
activeText: "#FFFFFF",
})}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: "row",
gap: 8,
padding: 5,
},
tag: {
borderWidth: 1,
borderRadius: 20,
paddingHorizontal: 10,
paddingVertical: 5,
minWidth: 100,
alignItems: "center",
},
tagText: {
fontSize: 14,
textAlign: "center",
},
});