diff --git a/QWEN.md b/QWEN.md
index de3f5de..8ee1eca 100644
--- a/QWEN.md
+++ b/QWEN.md
@@ -10,7 +10,7 @@ HIPMI Mobile is a cross-platform mobile application built with Expo and React Na
- **Architecture**: File-based routing with Expo Router
- **State Management**: Context API (AuthContext)
- **UI Components**: React Native Paper, custom components
-- **Maps Integration**: Mapbox Maps for React Native
+- **Maps Integration**: Maplibre Maps for React Native (`@maplibre/maplibre-react-native` v10.4.2)
- **Push Notifications**: React Native Firebase Messaging
- **Build System**: Metro bundler
- **Package Manager**: Bun
@@ -381,8 +381,8 @@ apiConfig.interceptors.request.use(async (config) => {
- Push Notifications (FCM)
- Configured for both iOS and Android
-### Mapbox
-- Map integration via `@rnmapbox/maps`
+### Maplibre
+- Map integration via `@maplibre/maplibre-react-native`
- Location permissions configured
### Deep Linking
@@ -475,10 +475,34 @@ rm -rf node_modules bun.lock
bun install
```
+### iOS Maplibre Crash Fix
+
+When using Maplibre MapView on iOS, prevent "Attempt to recycle a mounted view" crash:
+
+1. **Always render PointAnnotation** (not conditional)
+2. **Use opacity for visibility** instead of conditional rendering
+3. **Avoid key prop changes** that force remounting
+
+```typescript
+// ✅ GOOD: Stable PointAnnotation
+
+
+
+
+
+
+// ❌ BAD: Conditional rendering causes crash
+{selectedLocation && (
+
+)}
+```
+
## Documentation Files
- `docs/CHANGE_LOG.md` - Change log for recent updates
-- `docs/COMMIT_NOTES.md` - Commit notes and guidelines
- `docs/hipmi-note.md` - Build and deployment notes
- `docs/prompt-for-qwen-code.md` - Development prompts and patterns
@@ -488,3 +512,4 @@ bun install
- [React Native Documentation](https://reactnative.dev/)
- [Expo Router Documentation](https://docs.expo.dev/router/introduction/)
- [TypeScript Documentation](https://www.typescriptlang.org/docs/)
+- [Maplibre React Native](https://github.com/maplibre/maplibre-react-native)
diff --git a/app/(application)/(user)/maps/create.tsx b/app/(application)/(user)/maps/create.tsx
index 2c8416b..8052baa 100644
--- a/app/(application)/(user)/maps/create.tsx
+++ b/app/(application)/(user)/maps/create.tsx
@@ -1,143 +1,9 @@
-import {
- BaseBox,
- BoxButtonOnFooter,
- ButtonCenteredOnly,
- ButtonCustom,
- InformationBox,
- LandscapeFrameUploaded,
- Spacing,
- TextInputCustom,
- ViewWrapper,
-} from "@/components";
-import MapSelected from "@/components/Map/MapSelected";
-import DIRECTORY_ID from "@/constants/directory-id";
-import { useAuth } from "@/hooks/use-auth";
-import { apiMapsCreate } from "@/service/api-client/api-maps";
-import { uploadFileService } from "@/service/upload-service";
-import pickFile, { IFileData } from "@/utils/pickFile";
-import { router, useLocalSearchParams } from "expo-router";
-import { useState } from "react";
-import { LatLng } from "react-native-maps";
-import Toast from "react-native-toast-message";
+import Maps_ScreenMapsCreate from "@/screens/Maps/ScreenMapsCreate";
export default function MapsCreate() {
- const { user } = useAuth();
- const { id } = useLocalSearchParams();
- const [selectedLocation, setSelectedLocation] = useState(null);
- const [name, setName] = useState("");
- const [image, setImage] = useState(null);
- const [isLoading, setLoading] = useState(false);
-
- const handleSubmit = async () => {
- try {
- setLoading(true);
- let newData: any;
- newData = {
- authorId: user?.id,
- portofolioId: id,
- namePin: name,
- latitude: selectedLocation?.latitude,
- longitude: selectedLocation?.longitude,
- };
-
- if (image) {
- const responseUpload = await uploadFileService({
- dirId: DIRECTORY_ID.map_image,
- imageUri: image?.uri,
- });
-
- if (!responseUpload?.data?.id) {
- Toast.show({
- type: "error",
- text1: "Gagal mengunggah gambar",
- });
- return;
- }
-
- const imageId = responseUpload?.data?.id;
-
- newData = {
- authorId: user?.id,
- portofolioId: id,
- namePin: name,
- latitude: selectedLocation?.latitude,
- longitude: selectedLocation?.longitude,
- imageId: imageId,
- };
- }
-
- const response = await apiMapsCreate({
- data: newData,
- });
-
- if (!response.success) {
- Toast.show({
- type: "error",
- text1: "Gagal menambahkan map",
- });
- return;
- }
-
- Toast.show({
- type: "success",
- text1: "Map berhasil ditambahkan",
- });
- router.back();
- } catch (error) {
- console.log("[ERROR]", error);
- } finally {
- setLoading(false);
- }
- };
-
- const buttonFooter = (
-
-
- Simpan
-
-
- );
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- pickFile({
- allowedType: "image",
- setImageUri(file) {
- setImage(file);
- },
- });
- }}
- >
- Upload
-
-
-
+ <>
+
+ >
);
}
diff --git a/components/Map/MapSelectedPlatform.tsx b/components/Map/MapSelectedPlatform.tsx
new file mode 100644
index 0000000..a6d316f
--- /dev/null
+++ b/components/Map/MapSelectedPlatform.tsx
@@ -0,0 +1,112 @@
+import { Platform } from "react-native";
+import MapSelected from "./MapSelected";
+import { MapSelectedV2 } from "./MapSelectedV2";
+import { Region } from "./MapSelectedV2";
+import { LatLng } from "react-native-maps";
+
+/**
+ * Props untuk komponen MapSelectedPlatform
+ * Mendukung kedua format koordinat (LatLng untuk iOS, [number, number] untuk Android)
+ */
+export interface MapSelectedPlatformProps {
+ /** Region awal kamera */
+ initialRegion?: {
+ latitude?: number;
+ longitude?: number;
+ latitudeDelta?: number;
+ longitudeDelta?: number;
+ };
+
+ /** Lokasi yang dipilih (support kedua format) */
+ selectedLocation: LatLng | [number, number] | null;
+
+ /** Callback ketika lokasi dipilih */
+ onLocationSelect: (location: LatLng | [number, number]) => void;
+
+ /** Tinggi peta dalam pixels (default: 400) */
+ height?: number;
+
+ /** Tampilkan lokasi user (default: true) */
+ showUserLocation?: boolean;
+
+ /** Tampilkan tombol my location (default: true) */
+ showsMyLocationButton?: boolean;
+}
+
+/**
+ * Komponen Map yang otomatis memilih implementasi berdasarkan platform
+ *
+ * Platform Strategy:
+ * - **iOS**: Menggunakan react-native-maps (MapSelected)
+ * - **Android**: Menggunakan @maplibre/maplibre-react-native (MapSelectedV2)
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function MapSelectedPlatform({
+ initialRegion,
+ selectedLocation,
+ onLocationSelect,
+ height = 400,
+ showUserLocation = true,
+ showsMyLocationButton = true,
+}: MapSelectedPlatformProps) {
+ // iOS: Gunakan react-native-maps
+ if (Platform.OS === "ios") {
+ return (
+ {
+ onLocationSelect(location);
+ }}
+ height={height}
+ />
+ );
+ }
+
+ // Android: Gunakan MapLibre
+ // Konversi dari LatLng ke [longitude, latitude] jika perlu
+ const androidLocation: [number, number] | undefined = selectedLocation
+ ? isLatLng(selectedLocation)
+ ? [selectedLocation.longitude, selectedLocation.latitude]
+ : selectedLocation
+ : undefined;
+
+ return (
+ {
+ // Konversi dari [longitude, latitude] ke LatLng untuk konsistensi
+ const latLng: LatLng = {
+ latitude: location[1],
+ longitude: location[0],
+ };
+ onLocationSelect(latLng);
+ }}
+ height={height}
+ showUserLocation={showUserLocation}
+ showsMyLocationButton={showsMyLocationButton}
+ />
+ );
+}
+
+/**
+ * Type guard untuk mengecek apakah object adalah LatLng
+ */
+function isLatLng(location: any): location is LatLng {
+ return (
+ location &&
+ typeof location.latitude === "number" &&
+ typeof location.longitude === "number"
+ );
+}
+
+export default MapSelectedPlatform;
diff --git a/components/Map/MapSelectedV2.tsx b/components/Map/MapSelectedV2.tsx
new file mode 100644
index 0000000..c76e83e
--- /dev/null
+++ b/components/Map/MapSelectedV2.tsx
@@ -0,0 +1,163 @@
+import React, { useCallback, useMemo, useRef } from "react";
+import {
+ StyleSheet,
+ View,
+ ViewStyle,
+ StyleProp,
+} from "react-native";
+import { MainColor } from "@/constants/color-palet";
+import {
+ MapView,
+ Camera,
+ PointAnnotation,
+} from "@maplibre/maplibre-react-native";
+
+const DEFAULT_MAP_STYLE = "https://tiles.openfreemap.org/styles/liberty";
+
+export interface Region {
+ latitude: number;
+ longitude: number;
+ latitudeDelta: number;
+ longitudeDelta: number;
+}
+
+export interface MapSelectedV2Props {
+ initialRegion?: Region;
+ selectedLocation?: [number, number];
+ onLocationSelect?: (location: [number, number]) => void;
+ height?: number;
+ style?: StyleProp;
+ mapViewStyle?: StyleProp;
+ showUserLocation?: boolean;
+ showsMyLocationButton?: boolean;
+ mapStyle?: string;
+ zoomLevel?: number;
+}
+
+// ✅ Marker simple tanpa Animated — hapus pulse animation
+function SelectedLocationMarker({
+ color = MainColor.darkblue,
+}: {
+ size?: number;
+ color?: string;
+}) {
+ return (
+
+
+
+
+ );
+}
+
+export function MapSelectedV2({
+ initialRegion,
+ selectedLocation,
+ onLocationSelect,
+ height = 400,
+ style = styles.container,
+ mapViewStyle = styles.map,
+ mapStyle,
+ zoomLevel = 12,
+}: MapSelectedV2Props) {
+ const defaultRegion = useMemo(
+ () => ({
+ latitude: -8.737109,
+ longitude: 115.1756897,
+ latitudeDelta: 0.1,
+ longitudeDelta: 0.1,
+ }),
+ [],
+ );
+
+ const region = initialRegion || defaultRegion;
+
+ // ✅ Simpan initial center — TIDAK berubah saat user tap
+ const initialCenter = useRef<[number, number]>([
+ region.longitude,
+ region.latitude,
+ ]);
+
+ const handleMapPress = useCallback(
+ (event: any) => {
+ const coordinate = event?.geometry?.coordinates || event?.coordinates;
+ if (coordinate && Array.isArray(coordinate) && coordinate.length === 2) {
+ onLocationSelect?.([coordinate[0], coordinate[1]]);
+ }
+ },
+ [onLocationSelect],
+ );
+
+ return (
+
+
+ {/* ✅ Camera hanya set sekali di awal, tidak reactive ke selectedLocation */}
+
+
+ {/* ✅ Hanya render PointAnnotation jika ada selectedLocation */}
+ {/* ✅ Key statis — tidak pernah unmount/remount */}
+ {selectedLocation && (
+
+
+
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ width: "100%",
+ backgroundColor: "#f5f5f5",
+ overflow: "hidden",
+ borderRadius: 8,
+ },
+ map: {
+ flex: 1,
+ },
+ markerContainer: {
+ width: 40,
+ height: 40,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ // ✅ Ring statis pengganti pulse animation
+ markerRing: {
+ position: "absolute",
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ borderWidth: 2,
+ opacity: 0.4,
+ },
+ markerDot: {
+ width: 16,
+ height: 16,
+ borderRadius: 8,
+ borderWidth: 2,
+ borderColor: "#FFFFFF",
+ },
+});
+
+export default MapSelectedV2;
\ No newline at end of file
diff --git a/ios/HIPMIBadungConnect.xcodeproj/project.pbxproj b/ios/HIPMIBadungConnect.xcodeproj/project.pbxproj
index fa94b4c..b64e9e3 100644
--- a/ios/HIPMIBadungConnect.xcodeproj/project.pbxproj
+++ b/ios/HIPMIBadungConnect.xcodeproj/project.pbxproj
@@ -163,6 +163,8 @@
5A6E1555841A4B9D9D246E71 /* Remove signature files (Xcode workaround) */,
0B4282049A4A4293821DF904 /* Remove signature files (Xcode workaround) */,
CCCF75FD0B87410193A6B7DB /* Remove signature files (Xcode workaround) */,
+ 51F61F14096F4B7FBD9344A7 /* Remove signature files (Xcode workaround) */,
+ 60F3AE3AC4B24C2FA7FC8F10 /* Remove signature files (Xcode workaround) */,
);
buildRules = (
);
@@ -635,6 +637,40 @@
rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";
";
};
+ 51F61F14096F4B7FBD9344A7 /* Remove signature files (Xcode workaround) */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ name = "Remove signature files (Xcode workaround)";
+ inputPaths = (
+ );
+ outputPaths = (
+ );
+ shellPath = /bin/sh;
+ shellScript = "
+ echo \"Remove signature files (Xcode workaround)\";
+ rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";
+ ";
+ };
+ 60F3AE3AC4B24C2FA7FC8F10 /* Remove signature files (Xcode workaround) */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ name = "Remove signature files (Xcode workaround)";
+ inputPaths = (
+ );
+ outputPaths = (
+ );
+ shellPath = /bin/sh;
+ shellScript = "
+ echo \"Remove signature files (Xcode workaround)\";
+ rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";
+ ";
+ };
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
diff --git a/screens/Maps/DrawerMaps.tsx b/screens/Maps/DrawerMaps.tsx
index 31ddffa..c6249dc 100644
--- a/screens/Maps/DrawerMaps.tsx
+++ b/screens/Maps/DrawerMaps.tsx
@@ -40,8 +40,12 @@ export default function DrawerMaps({
closeDrawer={() => setOpenDrawer(false)}
height={"auto"}
>
-
-
+ {selected.imageId && (
+ <>
+
+
+ >
+ )}
(null);
+ const [name, setName] = useState("");
+ const [image, setImage] = useState(null);
+ const [isLoading, setLoading] = useState(false);
+
+ /**
+ * Handle submit form
+ * Upload image (jika ada) dan submit data maps ke API
+ */
+ const handleSubmit = async () => {
+ try {
+ setLoading(true);
+
+ // Prepare data tanpa image dulu
+ let newData: any = {
+ authorId: user?.id,
+ portofolioId: id,
+ namePin: name,
+ latitude: selectedLocation?.latitude,
+ longitude: selectedLocation?.longitude,
+ };
+
+ // Upload image jika ada
+ if (image) {
+ const responseUpload = await uploadFileService({
+ dirId: DIRECTORY_ID.map_image,
+ imageUri: image?.uri,
+ });
+
+ if (!responseUpload?.data?.id) {
+ Toast.show({
+ type: "error",
+ text1: "Gagal mengunggah gambar",
+ });
+ return;
+ }
+
+ const imageId = responseUpload?.data?.id;
+
+ newData = {
+ authorId: user?.id,
+ portofolioId: id,
+ namePin: name,
+ latitude: selectedLocation?.latitude,
+ longitude: selectedLocation?.longitude,
+ imageId: imageId,
+ };
+ }
+
+ // Submit ke API
+ const response = await apiMapsCreate({
+ data: newData,
+ });
+
+ if (!response.success) {
+ Toast.show({
+ type: "error",
+ text1: "Gagal menambahkan map",
+ });
+ return;
+ }
+
+ Toast.show({
+ type: "success",
+ text1: "Map berhasil ditambahkan",
+ });
+ router.back();
+ } catch (error) {
+ console.log("[ERROR]", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ /**
+ * Handle image upload
+ */
+ const handleImageUpload = async () => {
+ try {
+ await pickFile({
+ allowedType: "image",
+ setImageUri: (file) => {
+ setImage(file);
+ },
+ });
+ } catch (error) {
+ console.log("[MapsCreate] Image upload error:", error);
+ }
+ };
+
+ /**
+ * Footer component dengan button submit
+ */
+ const buttonFooter = (
+
+
+ Simpan
+
+
+ );
+
+ /**
+ * Render screen dengan NewWrapper
+ */
+ return (
+
+
+
+
+ {/* */}
+ {
+ // Set location (auto handle LatLng format)
+ setSelectedLocation(location as LatLng);
+ }}
+ height={300}
+ showUserLocation={true}
+ showsMyLocationButton={true}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ Upload
+
+
+
+
+ );
+}
+
+export default Maps_ScreenMapsCreate;