From f5d09a2906ecbb23d59eb357e5521df55adb96e3 Mon Sep 17 00:00:00 2001 From: bagasbanuna Date: Fri, 27 Feb 2026 16:57:01 +0800 Subject: [PATCH] Fix create 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 --- QWEN.md | 33 ++- app/(application)/(user)/maps/create.tsx | 142 +------------ components/Map/MapSelectedPlatform.tsx | 112 ++++++++++ components/Map/MapSelectedV2.tsx | 163 +++++++++++++++ .../project.pbxproj | 36 ++++ screens/Maps/DrawerMaps.tsx | 8 +- screens/Maps/ScreenMapsCreate.tsx | 192 ++++++++++++++++++ 7 files changed, 542 insertions(+), 144 deletions(-) create mode 100644 components/Map/MapSelectedPlatform.tsx create mode 100644 components/Map/MapSelectedV2.tsx create mode 100644 screens/Maps/ScreenMapsCreate.tsx 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;