Fix Maps
iOS Project - HIPMIBadungConnect.xcodeproj/project.pbxproj Maps & Location Screens - screens/Maps/MapsView2.tsx - screens/Portofolio/BusinessLocationSection.tsx New Map Components - components/Map/MapsV2Custom.tsx - components/Map/SelectLocationMap.tsx ### No Issue
This commit is contained in:
542
components/Map/MapsV2Custom.tsx
Normal file
542
components/Map/MapsV2Custom.tsx
Normal file
@@ -0,0 +1,542 @@
|
||||
import { ReactNode, useCallback, useMemo, useState, useEffect } from "react";
|
||||
import {
|
||||
Image,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
StyleProp,
|
||||
Animated,
|
||||
Easing,
|
||||
} from "react-native";
|
||||
|
||||
import API_IMAGE from "@/constants/api-storage";
|
||||
import { MainColor } from "@/constants/color-palet";
|
||||
import {
|
||||
Camera,
|
||||
MapView,
|
||||
PointAnnotation,
|
||||
} from "@maplibre/maplibre-react-native";
|
||||
|
||||
// Style peta default
|
||||
const DEFAULT_MAP_STYLE = "https://tiles.openfreemap.org/styles/liberty";
|
||||
|
||||
// Region default (Bali, Indonesia)
|
||||
const DEFAULT_REGION = {
|
||||
latitude: -8.737109,
|
||||
longitude: 115.1756897,
|
||||
latitudeDelta: 0.1,
|
||||
longitudeDelta: 0.1,
|
||||
};
|
||||
|
||||
// Zoom level default
|
||||
const DEFAULT_ZOOM_LEVEL = 12;
|
||||
|
||||
// Ukuran marker default
|
||||
const DEFAULT_MARKER_SIZE = 30;
|
||||
|
||||
/**
|
||||
* Interface data marker untuk MapsV2Custom
|
||||
*/
|
||||
export interface MapMarker {
|
||||
id: string;
|
||||
coordinate: [number, number]; // [longitude, latitude]
|
||||
imageId?: string;
|
||||
imageUrl?: string;
|
||||
onSelected?: () => void;
|
||||
[key: string]: any; // Izinkan properti custom tambahan
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface region untuk positioning kamera
|
||||
*/
|
||||
export interface Region {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
latitudeDelta: number;
|
||||
longitudeDelta: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props untuk komponen MapsV2Custom
|
||||
*/
|
||||
export interface MapsV2CustomProps {
|
||||
/** URL style peta custom (default: liberty style) */
|
||||
mapStyle?: string;
|
||||
|
||||
/** Override style container */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
|
||||
/** Override style MapView */
|
||||
mapViewStyle?: StyleProp<ViewStyle>;
|
||||
|
||||
/** Region awal kamera */
|
||||
initialRegion?: Region;
|
||||
|
||||
/** Zoom level awal (default: 12) */
|
||||
zoomLevel?: number;
|
||||
|
||||
/**
|
||||
* Data marker - mendukung single marker atau array of markers
|
||||
* @example
|
||||
* // Single marker
|
||||
* markers={{ id: "1", coordinate: [115.175, -8.737], imageId: "abc" }}
|
||||
*
|
||||
* @example
|
||||
* // Multiple markers
|
||||
* markers={[
|
||||
* { id: "1", coordinate: [115.175, -8.737], imageId: "abc" },
|
||||
* { id: "2", coordinate: [115.180, -8.740], imageId: "xyz" }
|
||||
* ]}
|
||||
*/
|
||||
markers?: MapMarker | MapMarker[];
|
||||
|
||||
/** Custom renderer marker */
|
||||
renderMarker?: (marker: MapMarker) => ReactNode;
|
||||
|
||||
/** Callback ketika marker ditekan */
|
||||
onMarkerPress?: (marker: MapMarker) => void;
|
||||
|
||||
/** Gunakan style marker image default (default: true jika markers disediakan) */
|
||||
showDefaultMarkers?: boolean;
|
||||
|
||||
/** Ukuran untuk marker default (default: 30) */
|
||||
markerSize?: number;
|
||||
|
||||
/** Warna border untuk marker default */
|
||||
markerBorderColor?: string;
|
||||
|
||||
/** Children tambahan untuk MapView (custom overlays, dll.) */
|
||||
children?: ReactNode;
|
||||
|
||||
/** Handler untuk tekan pada peta */
|
||||
onMapPress?: (coordinates: [number, number]) => void;
|
||||
|
||||
/** Test identifier */
|
||||
testID?: string;
|
||||
|
||||
/** Props tambahan untuk Camera */
|
||||
cameraProps?: Partial<Omit<React.ComponentProps<typeof Camera>, "centerCoordinate" | "zoomLevel">>;
|
||||
|
||||
/** Props tambahan untuk MapView */
|
||||
mapViewProps?: Partial<React.ComponentProps<typeof MapView>>;
|
||||
|
||||
/** Props tambahan untuk PointAnnotation */
|
||||
annotationProps?: Partial<{
|
||||
id: string;
|
||||
title?: string;
|
||||
snippet?: string;
|
||||
selected?: boolean;
|
||||
draggable?: boolean;
|
||||
coordinate: number[];
|
||||
anchor?: { x: number; y: number };
|
||||
onSelected?: (payload: any) => void;
|
||||
onDeselected?: (payload: any) => void;
|
||||
onDragStart?: (payload: any) => void;
|
||||
onDragEnd?: (payload: any) => void;
|
||||
onDrag?: (payload: any) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisasi markers ke array - mendukung single marker atau array
|
||||
*/
|
||||
function normalizeMarkers(markers: MapMarker | MapMarker[] | undefined): MapMarker[] {
|
||||
if (!markers) return [];
|
||||
return Array.isArray(markers) ? markers : [markers];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validasi marker memiliki props yang required (hanya development mode)
|
||||
*/
|
||||
function validateMarker(marker: MapMarker, index: number): boolean {
|
||||
if (!marker.id) {
|
||||
console.warn(`[MapsV2Custom] Marker pada index ${index} tidak memiliki prop 'id' yang required`);
|
||||
return false;
|
||||
}
|
||||
if (!marker.coordinate || !Array.isArray(marker.coordinate) || marker.coordinate.length !== 2) {
|
||||
console.warn(`[MapsV2Custom] Marker pada index ${index} tidak memiliki prop 'coordinate' yang required. Format yang diharapkan: [longitude, latitude]`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Komponen skeleton untuk loading state dengan shimmer animation
|
||||
*/
|
||||
function SkeletonMarker({
|
||||
size = DEFAULT_MARKER_SIZE,
|
||||
borderColor = MainColor.darkblue,
|
||||
loadingColor = "#C5C5C5",
|
||||
}: {
|
||||
size?: number;
|
||||
borderColor?: string;
|
||||
loadingColor?: string;
|
||||
}) {
|
||||
const shimmerAnim = useMemo(() => new Animated.Value(0), []);
|
||||
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(shimmerAnim, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(shimmerAnim, {
|
||||
toValue: 0,
|
||||
duration: 800,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
animation.start();
|
||||
|
||||
return () => animation.stop();
|
||||
}, [shimmerAnim]);
|
||||
|
||||
const shimmerOpacity = shimmerAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.3, 0.7],
|
||||
});
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.markerContainer,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
borderColor,
|
||||
backgroundColor: loadingColor,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.skeletonShimmer,
|
||||
{
|
||||
opacity: shimmerOpacity,
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Komponen fallback untuk error state
|
||||
*/
|
||||
function FallbackMarker({
|
||||
size = DEFAULT_MARKER_SIZE,
|
||||
borderColor = MainColor.darkblue,
|
||||
iconColor = MainColor.darkblue,
|
||||
}: {
|
||||
size?: number;
|
||||
borderColor?: string;
|
||||
iconColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.markerContainer,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
borderColor,
|
||||
backgroundColor: "#F5F5F5",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.fallbackIcon, { borderColor: iconColor }]}>
|
||||
<View style={[styles.fallbackIconInner, { backgroundColor: iconColor }]} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Props untuk DefaultMarker component
|
||||
*/
|
||||
export interface DefaultMarkerProps {
|
||||
/** ID file image dari API */
|
||||
imageId?: string;
|
||||
/** URL image langsung */
|
||||
imageUrl?: string;
|
||||
/** Ukuran marker (default: 30) */
|
||||
size?: number;
|
||||
/** Warna border marker (default: darkblue) */
|
||||
borderColor?: string;
|
||||
/** Warna skeleton loading (default: gray) */
|
||||
loadingColor?: string;
|
||||
/** Warna icon fallback (default: darkblue) */
|
||||
fallbackIconColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Komponen marker default dengan image, border, shadows, loading skeleton, dan error fallback
|
||||
*/
|
||||
export function DefaultMarker({
|
||||
imageId,
|
||||
imageUrl,
|
||||
size = DEFAULT_MARKER_SIZE,
|
||||
borderColor = MainColor.darkblue,
|
||||
loadingColor = MainColor.white_gray,
|
||||
fallbackIconColor = MainColor.darkblue,
|
||||
}: DefaultMarkerProps) {
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const uri = imageId ? API_IMAGE.GET({ fileId: imageId }) : imageUrl;
|
||||
|
||||
// Debug log untuk development
|
||||
if (__DEV__ && uri) {
|
||||
console.log("[DefaultMarker] Image URI:", uri);
|
||||
}
|
||||
|
||||
const handleError = useCallback((error: any) => {
|
||||
console.log("[DefaultMarker] Image error:", error?.nativeEvent?.error || error);
|
||||
setHasError(true);
|
||||
}, []);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
console.log("[DefaultMarker] Image loaded successfully");
|
||||
}, []);
|
||||
|
||||
// Jika tidak ada URI atau error, tampilkan fallback
|
||||
if (!uri || hasError) {
|
||||
return (
|
||||
<FallbackMarker
|
||||
size={size}
|
||||
borderColor={borderColor}
|
||||
iconColor={fallbackIconColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render image dengan placeholder (defaultSource) untuk loading state
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.markerContainer,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
borderColor,
|
||||
backgroundColor: loadingColor, // Background color sebagai placeholder
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={[styles.markerImage, { width: size, height: size }]}
|
||||
resizeMode="cover"
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
// Placeholder untuk Android saat loading
|
||||
defaultSource={require("@/assets/images/icon.png")}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Komponen Map yang reusable dan customizable menggunakan Mapbox/MapLibre
|
||||
*
|
||||
* Mendukung single marker, multiple markers, atau empty state.
|
||||
*
|
||||
* @example
|
||||
* // Single marker
|
||||
* <MapsV2Custom
|
||||
* markers={{
|
||||
* id: "1",
|
||||
* coordinate: [115.1756897, -8.737109],
|
||||
* imageId: "file-123"
|
||||
* }}
|
||||
* />
|
||||
*
|
||||
* @example
|
||||
* // Multiple markers
|
||||
* <MapsV2Custom
|
||||
* markers={[
|
||||
* { id: "1", coordinate: [115.175, -8.737], imageId: "abc" },
|
||||
* { id: "2", coordinate: [115.180, -8.740], imageId: "xyz" }
|
||||
* ]}
|
||||
* />
|
||||
*
|
||||
* @example
|
||||
* // Peta dengan custom style dan custom markers
|
||||
* <MapsV2Custom
|
||||
* mapStyle="https://your-custom-style.com"
|
||||
* markers={markers}
|
||||
* markerSize={40}
|
||||
* markerBorderColor={MainColor.primary}
|
||||
* />
|
||||
*
|
||||
* @example
|
||||
* // Dengan custom marker renderer
|
||||
* <MapsV2Custom
|
||||
* markers={data}
|
||||
* renderMarker={(marker) => <CustomMarker {...marker} />}
|
||||
* />
|
||||
*
|
||||
* @example
|
||||
* // Peta kosong (tanpa markers)
|
||||
* <MapsV2Custom
|
||||
* initialRegion={{ latitude: -6.2, longitude: 106.8, latitudeDelta: 0.1, longitudeDelta: 0.1 }}
|
||||
* />
|
||||
*/
|
||||
export function MapsV2Custom({
|
||||
mapStyle = DEFAULT_MAP_STYLE,
|
||||
style = styles.container,
|
||||
mapViewStyle = styles.map,
|
||||
initialRegion = DEFAULT_REGION,
|
||||
zoomLevel = DEFAULT_ZOOM_LEVEL,
|
||||
markers,
|
||||
renderMarker,
|
||||
onMarkerPress,
|
||||
showDefaultMarkers = true,
|
||||
markerSize = DEFAULT_MARKER_SIZE,
|
||||
markerBorderColor = MainColor.darkblue,
|
||||
children,
|
||||
onMapPress,
|
||||
testID,
|
||||
cameraProps,
|
||||
mapViewProps,
|
||||
annotationProps,
|
||||
}: MapsV2CustomProps) {
|
||||
// Normalisasi markers ke array (mendukung single atau multiple)
|
||||
const normalizedMarkers = useMemo(
|
||||
() => {
|
||||
const arr = normalizeMarkers(markers);
|
||||
// Filter marker yang invalid
|
||||
return arr.filter((marker) => {
|
||||
if (!marker.id) {
|
||||
console.warn("[MapsV2Custom] Marker tanpa id akan diabaikan");
|
||||
return false;
|
||||
}
|
||||
if (!marker.coordinate || marker.coordinate.length !== 2) {
|
||||
console.warn("[MapsV2Custom] Marker tanpa coordinate valid akan diabaikan");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
[markers]
|
||||
);
|
||||
|
||||
// Validasi markers dalam development mode
|
||||
useMemo(() => {
|
||||
if (__DEV__) {
|
||||
normalizedMarkers.forEach((marker, index) => {
|
||||
validateMarker(marker, index);
|
||||
});
|
||||
}
|
||||
}, [normalizedMarkers]);
|
||||
|
||||
const handleMarkerSelected = useCallback(
|
||||
(marker: MapMarker) => {
|
||||
if (marker.onSelected) {
|
||||
marker.onSelected();
|
||||
}
|
||||
if (onMarkerPress) {
|
||||
onMarkerPress(marker);
|
||||
}
|
||||
},
|
||||
[onMarkerPress]
|
||||
);
|
||||
|
||||
const renderMarkerComponent = useCallback(
|
||||
(marker: MapMarker): ReactNode => {
|
||||
if (renderMarker) {
|
||||
return renderMarker(marker);
|
||||
}
|
||||
|
||||
if (showDefaultMarkers) {
|
||||
return (
|
||||
<DefaultMarker
|
||||
imageId={marker.imageId}
|
||||
imageUrl={marker.imageUrl}
|
||||
size={markerSize}
|
||||
borderColor={markerBorderColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[renderMarker, showDefaultMarkers, markerSize, markerBorderColor]
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={style} testID={testID}>
|
||||
<MapView style={mapViewStyle} mapStyle={mapStyle} {...mapViewProps}>
|
||||
<Camera
|
||||
zoomLevel={zoomLevel}
|
||||
centerCoordinate={[initialRegion.longitude, initialRegion.latitude]}
|
||||
{...cameraProps}
|
||||
/>
|
||||
|
||||
{normalizedMarkers.map((marker) => (
|
||||
<PointAnnotation
|
||||
key={marker.id}
|
||||
id={marker.id}
|
||||
coordinate={marker.coordinate}
|
||||
onSelected={() => handleMarkerSelected(marker)}
|
||||
{...annotationProps}
|
||||
>
|
||||
{renderMarkerComponent(marker) as any}
|
||||
</PointAnnotation>
|
||||
))}
|
||||
|
||||
{children}
|
||||
</MapView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
map: { flex: 1 },
|
||||
markerContainer: {
|
||||
overflow: "hidden",
|
||||
borderWidth: 1,
|
||||
elevation: 4,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 3,
|
||||
},
|
||||
markerImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
skeletonShimmer: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 999,
|
||||
},
|
||||
fallbackIcon: {
|
||||
width: "60%",
|
||||
height: "60%",
|
||||
borderRadius: 999,
|
||||
borderWidth: 2,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
fallbackIconInner: {
|
||||
width: "40%",
|
||||
height: "40%",
|
||||
borderRadius: 999,
|
||||
},
|
||||
});
|
||||
272
components/Map/SelectLocationMap.tsx
Normal file
272
components/Map/SelectLocationMap.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import React, { useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import {
|
||||
MapView,
|
||||
Camera,
|
||||
PointAnnotation,
|
||||
MarkerView,
|
||||
} from "@maplibre/maplibre-react-native";
|
||||
import * as Location from "expo-location";
|
||||
import { useFocusEffect, useRouter } from "expo-router";
|
||||
|
||||
const MAP_STYLE = "https://tiles.openfreemap.org/styles/liberty";
|
||||
|
||||
type Coordinate = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
export default function SelectLocationMap() {
|
||||
const router = useRouter();
|
||||
const annotationRef = useRef<any>(null);
|
||||
|
||||
const [selectedCoord, setSelectedCoord] = useState<Coordinate | null>(null);
|
||||
const [address, setAddress] = useState<string>("");
|
||||
const [isLoadingAddress, setIsLoadingAddress] = useState(false);
|
||||
const [cameraCenter, setCameraCenter] = useState<[number, number]>([
|
||||
106.8272, -6.1751,
|
||||
]);
|
||||
|
||||
const reverseGeocode = async (coord: Coordinate): Promise<string> => {
|
||||
try {
|
||||
const { status } = await Location.getForegroundPermissionsAsync();
|
||||
if (status !== "granted") {
|
||||
await Location.requestForegroundPermissionsAsync();
|
||||
}
|
||||
|
||||
const results = await Location.reverseGeocodeAsync({
|
||||
latitude: coord.latitude,
|
||||
longitude: coord.longitude,
|
||||
});
|
||||
|
||||
if (!results || results.length === 0) return "Alamat tidak ditemukan";
|
||||
|
||||
const loc = results[0];
|
||||
const parts = [
|
||||
loc.street,
|
||||
loc.district,
|
||||
loc.subregion,
|
||||
loc.city,
|
||||
loc.region,
|
||||
loc.country,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length > 0 ? parts.join(", ") : "Alamat tidak ditemukan";
|
||||
} catch (error: any) {
|
||||
console.log("reverseGeocode error:", error?.message || error);
|
||||
return "Gagal mengambil alamat";
|
||||
}
|
||||
};
|
||||
|
||||
const handleMapPress = useCallback(async (event: any) => {
|
||||
try {
|
||||
const coordinates = event?.geometry?.coordinates;
|
||||
if (!coordinates) return;
|
||||
|
||||
const [longitude, latitude] = coordinates;
|
||||
if (!longitude || !latitude) return;
|
||||
|
||||
const coord: Coordinate = { latitude, longitude };
|
||||
|
||||
// ✅ Update state koordinat, BUKAN ganti key
|
||||
setSelectedCoord(coord);
|
||||
setCameraCenter([longitude, latitude]);
|
||||
setAddress("");
|
||||
setIsLoadingAddress(true);
|
||||
|
||||
const resolvedAddress = await reverseGeocode(coord);
|
||||
setAddress(resolvedAddress);
|
||||
setIsLoadingAddress(false);
|
||||
|
||||
// ✅ Refresh annotation tanpa unmount
|
||||
annotationRef.current?.refresh?.();
|
||||
} catch (error: any) {
|
||||
console.log("handleMapPress error:", error?.message || error);
|
||||
setIsLoadingAddress(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedCoord) return;
|
||||
router.navigate({
|
||||
pathname: "/maps/create",
|
||||
params: {
|
||||
latitude: String(selectedCoord.latitude),
|
||||
longitude: String(selectedCoord.longitude),
|
||||
address,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Sembunyikan marker sebelum halaman unmount
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
return () => {
|
||||
// Cleanup saat leave — sembunyikan marker dulu sebelum unmount
|
||||
setSelectedCoord(null);
|
||||
};
|
||||
}, []),
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MapView style={styles.map} mapStyle={MAP_STYLE} onPress={handleMapPress}>
|
||||
<Camera
|
||||
zoomLevel={14}
|
||||
centerCoordinate={cameraCenter}
|
||||
animationMode="flyTo"
|
||||
animationDuration={300}
|
||||
/>
|
||||
|
||||
{/* ✅ Key statis — tidak pernah berubah, tidak unmount/remount */}
|
||||
{selectedCoord && (
|
||||
<MarkerView
|
||||
id="selected-marker"
|
||||
coordinate={[selectedCoord.longitude, selectedCoord.latitude]}
|
||||
anchor={{ x: 0.5, y: 1 }} // Anchor bawah tengah
|
||||
>
|
||||
<View style={styles.pin}>
|
||||
<View style={styles.pinDot} />
|
||||
</View>
|
||||
</MarkerView>
|
||||
)}
|
||||
</MapView>
|
||||
|
||||
{/* Bottom Sheet */}
|
||||
<View style={styles.bottomSheet}>
|
||||
{!selectedCoord ? (
|
||||
<Text style={styles.hintText}>
|
||||
Tap pada peta untuk memilih lokasi
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.coordRow}>
|
||||
<View style={styles.coordItem}>
|
||||
<Text style={styles.coordLabel}>Latitude</Text>
|
||||
<Text style={styles.coordValue}>
|
||||
{selectedCoord.latitude.toFixed(6)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.dividerVertical} />
|
||||
<View style={styles.coordItem}>
|
||||
<Text style={styles.coordLabel}>Longitude</Text>
|
||||
<Text style={styles.coordValue}>
|
||||
{selectedCoord.longitude.toFixed(6)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.addressContainer}>
|
||||
<Text style={styles.coordLabel}>Alamat</Text>
|
||||
{isLoadingAddress ? (
|
||||
<ActivityIndicator size="small" color="#0a1f44" />
|
||||
) : (
|
||||
<Text style={styles.addressText} numberOfLines={2}>
|
||||
{address || "-"}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.confirmButton,
|
||||
isLoadingAddress && styles.confirmButtonDisabled,
|
||||
]}
|
||||
onPress={handleConfirm}
|
||||
disabled={isLoadingAddress}
|
||||
>
|
||||
<Text style={styles.confirmButtonText}>Konfirmasi Lokasi</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
map: { flex: 1 },
|
||||
pin: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 100,
|
||||
backgroundColor: "#0a1f44",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderWidth: 2,
|
||||
borderColor: "#fff",
|
||||
},
|
||||
pinDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 100,
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
bottomSheet: {
|
||||
backgroundColor: "#fff",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 20,
|
||||
paddingBottom: 32,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
elevation: 10,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: -3 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 6,
|
||||
minHeight: 140,
|
||||
justifyContent: "center",
|
||||
},
|
||||
hintText: {
|
||||
textAlign: "center",
|
||||
color: "#888",
|
||||
fontSize: 14,
|
||||
},
|
||||
coordRow: {
|
||||
flexDirection: "row",
|
||||
marginBottom: 12,
|
||||
},
|
||||
coordItem: { flex: 1 },
|
||||
dividerVertical: {
|
||||
width: 1,
|
||||
backgroundColor: "#e0e0e0",
|
||||
marginHorizontal: 12,
|
||||
},
|
||||
coordLabel: {
|
||||
fontSize: 11,
|
||||
color: "#888",
|
||||
marginBottom: 2,
|
||||
},
|
||||
coordValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#0a1f44",
|
||||
},
|
||||
addressContainer: { marginBottom: 16 },
|
||||
addressText: {
|
||||
fontSize: 13,
|
||||
color: "#333",
|
||||
lineHeight: 18,
|
||||
},
|
||||
confirmButton: {
|
||||
backgroundColor: "#0a1f44",
|
||||
borderRadius: 12,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
},
|
||||
confirmButtonDisabled: {
|
||||
backgroundColor: "#aaa",
|
||||
},
|
||||
confirmButtonText: {
|
||||
color: "#fff",
|
||||
fontWeight: "700",
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
@@ -1,19 +1,13 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Image, StyleSheet, View } from "react-native";
|
||||
|
||||
// Cek versi >= 10.x gunakan ini
|
||||
import API_IMAGE from "@/constants/api-storage";
|
||||
import { MainColor } from "@/constants/color-palet";
|
||||
import { apiMapsGetAll } from "@/service/api-client/api-maps";
|
||||
import {
|
||||
Camera,
|
||||
MapView,
|
||||
PointAnnotation,
|
||||
} from "@maplibre/maplibre-react-native";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import DrawerMaps from "./DrawerMaps";
|
||||
|
||||
const MAP_STYLE = "https://tiles.openfreemap.org/styles/liberty";
|
||||
import { ViewWrapper } from "@/components";
|
||||
import { MapMarker, MapsV2Custom } from "@/components/Map/MapsV2Custom";
|
||||
|
||||
import DrawerMaps from "./DrawerMaps";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
interface TypeMaps {
|
||||
id: string;
|
||||
@@ -40,14 +34,6 @@ interface TypeMaps {
|
||||
};
|
||||
}
|
||||
|
||||
const defaultRegion = {
|
||||
latitude: -8.737109,
|
||||
longitude: 115.1756897,
|
||||
latitudeDelta: 0.1,
|
||||
longitudeDelta: 0.1,
|
||||
height: 300,
|
||||
};
|
||||
|
||||
export default function MapsView2() {
|
||||
const [list, setList] = useState<TypeMaps[] | null>(null);
|
||||
const [loadList, setLoadList] = useState(false);
|
||||
@@ -76,7 +62,6 @@ export default function MapsView2() {
|
||||
const response = await apiMapsGetAll();
|
||||
|
||||
if (response.success) {
|
||||
// console.log("[RESPONSE]", JSON.stringify(response.data, null, 2));
|
||||
setList(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -86,53 +71,31 @@ export default function MapsView2() {
|
||||
}
|
||||
};
|
||||
|
||||
const markers: MapMarker[] = list?.map((item) => ({
|
||||
id: item.id,
|
||||
coordinate: [item.longitude, item.latitude] as [number, number],
|
||||
imageId: item.Portofolio.logoId,
|
||||
onSelected: () => {
|
||||
setOpenDrawer(true);
|
||||
setSelected({
|
||||
id: item.id,
|
||||
bidangBisnis: item.Portofolio.MasterBidangBisnis.name,
|
||||
nomorTelepon: item.Portofolio.tlpn,
|
||||
alamatBisnis: item.Portofolio.alamatKantor,
|
||||
namePin: item.namePin,
|
||||
imageId: item.imageId,
|
||||
portofolioId: item.Portofolio.id,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
});
|
||||
},
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.container}>
|
||||
<MapView style={styles.map} mapStyle={MAP_STYLE}>
|
||||
<Camera
|
||||
zoomLevel={12}
|
||||
centerCoordinate={[defaultRegion.longitude, defaultRegion.latitude]}
|
||||
/>
|
||||
|
||||
{list?.map((item: TypeMaps) => {
|
||||
const imageUrl = API_IMAGE.GET({ fileId: item.Portofolio.logoId });
|
||||
|
||||
return (
|
||||
<PointAnnotation
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
coordinate={[item.longitude, item.latitude] as [number, number]}
|
||||
onSelected={() => {
|
||||
setOpenDrawer(true);
|
||||
setSelected({
|
||||
id: item?.id,
|
||||
bidangBisnis: item?.Portofolio?.MasterBidangBisnis?.name,
|
||||
nomorTelepon: item?.Portofolio?.tlpn,
|
||||
alamatBisnis: item?.Portofolio?.alamatKantor,
|
||||
namePin: item?.namePin,
|
||||
imageId: item?.imageId,
|
||||
portofolioId: item?.Portofolio?.id,
|
||||
latitude: item?.latitude,
|
||||
longitude: item?.longitude,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<View style={styles.markerContainer}>
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={styles.markerImage}
|
||||
resizeMode="cover"
|
||||
onError={(e: any) =>
|
||||
console.log("Image error:", e.nativeEvent.error)
|
||||
} // Tangkap error image
|
||||
/>
|
||||
</View>
|
||||
</PointAnnotation>
|
||||
);
|
||||
})}
|
||||
</MapView>
|
||||
</View>
|
||||
<ViewWrapper>
|
||||
<MapsV2Custom markers={markers} />
|
||||
</ViewWrapper>
|
||||
|
||||
<DrawerMaps
|
||||
openDrawer={openDrawer}
|
||||
@@ -144,23 +107,9 @@ export default function MapsView2() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
map: { flex: 1 },
|
||||
markerContainer: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 100,
|
||||
overflow: "hidden", // Wajib agar borderRadius terapply pada Image
|
||||
borderWidth: 1,
|
||||
borderColor: MainColor.darkblue, // Opsional, biar lebih cantik
|
||||
elevation: 4, // Shadow Android
|
||||
shadowColor: "#000", // Shadow iOS
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 3,
|
||||
map: {
|
||||
flex: 1,
|
||||
width: "50%",
|
||||
maxHeight: "50%",
|
||||
},
|
||||
markerImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,42 @@
|
||||
import {
|
||||
BaseBox,
|
||||
MapCustom,
|
||||
StackCustom,
|
||||
TextCustom
|
||||
} from "@/components";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
|
||||
import { BaseBox, StackCustom, TextCustom } from "@/components";
|
||||
import { MapsV2Custom } from "@/components/Map/MapsV2Custom";
|
||||
|
||||
export default function Portofolio_BusinessLocation({
|
||||
data,
|
||||
imageId,
|
||||
setOpenDrawerLocation,
|
||||
}: {
|
||||
data: any;
|
||||
data: {
|
||||
id: string;
|
||||
imageId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
namePin: string;
|
||||
pinId: string;
|
||||
} | null;
|
||||
|
||||
imageId?: string;
|
||||
setOpenDrawerLocation: (value: boolean) => void;
|
||||
}) {
|
||||
console.log("data", data);
|
||||
|
||||
// Buat marker hanya jika data lengkap
|
||||
const markers =
|
||||
data?.latitude && data?.longitude
|
||||
? [
|
||||
{
|
||||
id: data.id || "location-marker",
|
||||
coordinate: [data.longitude, data.latitude] as [number, number],
|
||||
imageId,
|
||||
onSelected: () => {
|
||||
setOpenDrawerLocation(true);
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseBox style={{ height: !data ? 200 : "auto" }}>
|
||||
@@ -30,18 +53,33 @@ export default function Portofolio_BusinessLocation({
|
||||
Lokasi bisnis belum ditambahkan
|
||||
</TextCustom>
|
||||
) : (
|
||||
<MapCustom
|
||||
latitude={data?.latitude}
|
||||
longitude={data?.longitude}
|
||||
namePin={data?.namePin}
|
||||
imageId={imageId}
|
||||
onPress={() => {
|
||||
setOpenDrawerLocation(true);
|
||||
}}
|
||||
/>
|
||||
<View style={styles.mapContainer}>
|
||||
<MapsV2Custom
|
||||
markers={markers}
|
||||
zoomLevel={15}
|
||||
showDefaultMarkers={true}
|
||||
markerSize={35}
|
||||
initialRegion={{
|
||||
latitude: data?.latitude,
|
||||
longitude: data?.longitude,
|
||||
latitudeDelta: 0.1,
|
||||
longitudeDelta: 0.1,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</StackCustom>
|
||||
</BaseBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
mapContainer: {
|
||||
width: "100%",
|
||||
height: 250,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user