From f5d09a2906ecbb23d59eb357e5521df55adb96e3 Mon Sep 17 00:00:00 2001 From: bagasbanuna Date: Fri, 27 Feb 2026 16:57:01 +0800 Subject: [PATCH 1/3] 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; -- 2.49.1 From 4c63485a5b90df103788d0813c3cddf4f01f9536 Mon Sep 17 00:00:00 2001 From: bagasbanuna Date: Mon, 2 Mar 2026 10:31:29 +0800 Subject: [PATCH 2/3] Clean Code Edit Maps Maps Edit Feature - app/(application)/(user)/maps/[id]/edit.tsx - components/Map/MapSelectedV2.tsx Docs - docs/prompt-for-qwen-code.md New Screen - screens/Maps/ScreenMapsEdit.tsx ### No Issue --- app/(application)/(user)/maps/[id]/edit.tsx | 243 +------------------ components/Map/MapSelectedV2.tsx | 1 + docs/prompt-for-qwen-code.md | 10 +- screens/Maps/ScreenMapsEdit.tsx | 245 ++++++++++++++++++++ 4 files changed, 254 insertions(+), 245 deletions(-) create mode 100644 screens/Maps/ScreenMapsEdit.tsx diff --git a/app/(application)/(user)/maps/[id]/edit.tsx b/app/(application)/(user)/maps/[id]/edit.tsx index 75f8894..c500870 100644 --- a/app/(application)/(user)/maps/[id]/edit.tsx +++ b/app/(application)/(user)/maps/[id]/edit.tsx @@ -1,244 +1,5 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -import { - BoxButtonOnFooter, - ButtonCenteredOnly, - ButtonCustom, - InformationBox, - LandscapeFrameUploaded, - Spacing, - TextInputCustom, - ViewWrapper, -} from "@/components"; -import API_IMAGE from "@/constants/api-storage"; -import DIRECTORY_ID from "@/constants/directory-id"; -import { apiMapsGetOne, apiMapsUpdate } from "@/service/api-client/api-maps"; -import { uploadFileService } from "@/service/upload-service"; -import pickFile, { IFileData } from "@/utils/pickFile"; -import { router, useFocusEffect, useLocalSearchParams } from "expo-router"; -import { useCallback, useState } from "react"; -import { StyleSheet, View } from "react-native"; -import MapView, { LatLng, Marker } from "react-native-maps"; -import Toast from "react-native-toast-message"; +import { Maps_ScreenMapsEdit } from "@/screens/Maps/ScreenMapsEdit"; -const defaultRegion = { - latitude: -8.737109, - longitude: 115.1756897, - latitudeDelta: 0.1, - longitudeDelta: 0.1, -}; export default function MapsEdit() { - const { id } = useLocalSearchParams(); - const [data, setData] = useState({ - id: "", - namePin: "", - latitude: "", - longitude: "", - imageId: "", - }); - const [selectedLocation, setSelectedLocation] = useState(null); - const [image, setImage] = useState(null); - const [isLoading, setLoading] = useState(false); - - useFocusEffect( - useCallback(() => { - onLoadData(); - }, [id]) - ); - - const onLoadData = async () => { - try { - const response = await apiMapsGetOne({ id: id as string }); - - if (response.success) { - setData({ - id: response.data.id, - namePin: response.data.namePin, - latitude: response.data.latitude, - longitude: response.data.longitude, - imageId: response.data.imageId, - }); - } - } catch (error) { - console.log("[ERROR]", error); - } - }; - - const handleMapPress = (event: any) => { - const { latitude, longitude } = event.nativeEvent.coordinate; - const location = { latitude, longitude }; - setSelectedLocation(location); - }; - - const handleSubmit = async () => { - let newData: any; - if (!data.namePin) { - Toast.show({ - type: "error", - text1: "Nama pin harus diisi", - }); - return; - } - - newData = { - namePin: data?.namePin, - latitude: selectedLocation?.latitude || data?.latitude, - longitude: selectedLocation?.longitude || data?.longitude, - }; - - try { - setLoading(true); - 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 = { - namePin: data?.namePin, - latitude: selectedLocation?.latitude, - longitude: selectedLocation?.longitude, - newImageId: imageId, - }; - } - - const responseUpdate = await apiMapsUpdate({ - id: data?.id, - data: newData, - }); - - if (!responseUpdate.success) { - Toast.show({ - type: "error", - text1: "Gagal mengupdate map", - }); - return; - } - - Toast.show({ - type: "success", - text1: "Map berhasil diupdate", - }); - router.back(); - } catch (error) { - console.log("[ERROR]", error); - } finally { - setLoading(false); - } - }; - - const buttonFooter = ( - - - Update - - - ); - - return ( - - - - - - {selectedLocation ? ( - - ) : ( - - )} - - - - setData({ ...data, namePin: value })} - /> - - - - - - { - pickFile({ - allowedType: "image", - setImageUri(file) { - setImage(file); - }, - }); - }} - > - Upload - - - - ); + return ; } - -const styles = StyleSheet.create({ - container: { - width: "100%", - backgroundColor: "#f5f5f5", - overflow: "hidden", - borderRadius: 8, - marginBottom: 20, - }, - map: { - flex: 1, - }, -}); diff --git a/components/Map/MapSelectedV2.tsx b/components/Map/MapSelectedV2.tsx index c76e83e..286ce4a 100644 --- a/components/Map/MapSelectedV2.tsx +++ b/components/Map/MapSelectedV2.tsx @@ -81,6 +81,7 @@ export function MapSelectedV2({ (event: any) => { const coordinate = event?.geometry?.coordinates || event?.coordinates; if (coordinate && Array.isArray(coordinate) && coordinate.length === 2) { + console.log("[MapSelectedV2] coordinate", coordinate); onLocationSelect?.([coordinate[0], coordinate[1]]); } }, diff --git a/docs/prompt-for-qwen-code.md b/docs/prompt-for-qwen-code.md index 1f24be0..a7682cd 100644 --- a/docs/prompt-for-qwen-code.md +++ b/docs/prompt-for-qwen-code.md @@ -111,10 +111,12 @@ Jika tidak ada props page maka tambahkan props page dan default page: "1" ( stri Jika butuh refrensi FlatList bisa lihat pada file components/_ShareComponent/NewWrapper.tsx -File Utama: screens/Admin/Investment/ScreenInvestmentStatus.tsx -Folder tujuan: screens/Admin/Investment -Reffrensi: screens/Admin/Donation/BoxDonationStatus.tsx -Buatkan box component baru pada file "File Utama" di bagian renderItem agar lebih rapi buat file baru dengan nama BoxInvestmentStatus.tsx +File Utama: app/(application)/(user)/maps/[id]/edit.tsx +Folder tujuan: screens/Maps +Nama file utama: ScreenMapsEdit.tsx +Nama function utama: Maps_ScreenMapsEdit + +Buatkan file baru pada "Folder tujuan" dengan nama "Nama file utama" dan ubah nama function menjadi "Nama function utama" kemudian clean code, import dan panggil function tersebut pada file "File source" diff --git a/screens/Maps/ScreenMapsEdit.tsx b/screens/Maps/ScreenMapsEdit.tsx new file mode 100644 index 0000000..c451815 --- /dev/null +++ b/screens/Maps/ScreenMapsEdit.tsx @@ -0,0 +1,245 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { + BoxButtonOnFooter, + ButtonCenteredOnly, + ButtonCustom, + InformationBox, + LandscapeFrameUploaded, + Spacing, + TextInputCustom, + ViewWrapper, +} from "@/components"; +import API_IMAGE from "@/constants/api-storage"; +import DIRECTORY_ID from "@/constants/directory-id"; +import { apiMapsGetOne, apiMapsUpdate } from "@/service/api-client/api-maps"; +import { uploadFileService } from "@/service/upload-service"; +import pickFile, { IFileData } from "@/utils/pickFile"; +import { router, useFocusEffect, useLocalSearchParams } from "expo-router"; +import { useCallback, useState } from "react"; +import { StyleSheet, View } from "react-native"; +import MapView, { LatLng, Marker } from "react-native-maps"; +import Toast from "react-native-toast-message"; + +const defaultRegion = { + latitude: -8.737109, + longitude: 115.1756897, + latitudeDelta: 0.1, + longitudeDelta: 0.1, +}; + +export function Maps_ScreenMapsEdit() { + const { id } = useLocalSearchParams(); + const [data, setData] = useState({ + id: "", + namePin: "", + latitude: "", + longitude: "", + imageId: "", + }); + const [selectedLocation, setSelectedLocation] = useState(null); + const [image, setImage] = useState(null); + const [isLoading, setLoading] = useState(false); + + useFocusEffect( + useCallback(() => { + onLoadData(); + }, [id]) + ); + + const onLoadData = async () => { + try { + const response = await apiMapsGetOne({ id: id as string }); + + if (response.success) { + setData({ + id: response.data.id, + namePin: response.data.namePin, + latitude: response.data.latitude, + longitude: response.data.longitude, + imageId: response.data.imageId, + }); + } + } catch (error) { + console.log("[ERROR]", error); + } + }; + + const handleMapPress = (event: any) => { + const { latitude, longitude } = event.nativeEvent.coordinate; + const location = { latitude, longitude }; + setSelectedLocation(location); + }; + + const handleSubmit = async () => { + let newData: any; + if (!data.namePin) { + Toast.show({ + type: "error", + text1: "Nama pin harus diisi", + }); + return; + } + + newData = { + namePin: data?.namePin, + latitude: selectedLocation?.latitude || data?.latitude, + longitude: selectedLocation?.longitude || data?.longitude, + }; + + try { + setLoading(true); + 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 = { + namePin: data?.namePin, + latitude: selectedLocation?.latitude, + longitude: selectedLocation?.longitude, + newImageId: imageId, + }; + } + + const responseUpdate = await apiMapsUpdate({ + id: data?.id, + data: newData, + }); + + if (!responseUpdate.success) { + Toast.show({ + type: "error", + text1: "Gagal mengupdate map", + }); + return; + } + + Toast.show({ + type: "success", + text1: "Map berhasil diupdate", + }); + router.back(); + } catch (error) { + console.log("[ERROR]", error); + } finally { + setLoading(false); + } + }; + + const buttonFooter = ( + + + Update + + + ); + + return ( + + + + + + {selectedLocation ? ( + + ) : ( + + )} + + + + setData({ ...data, namePin: value })} + /> + + + + + + { + pickFile({ + allowedType: "image", + setImageUri(file) { + setImage(file); + }, + }); + }} + > + Upload + + + + ); +} + +const styles = StyleSheet.create({ + container: { + width: "100%", + backgroundColor: "#f5f5f5", + overflow: "hidden", + borderRadius: 8, + marginBottom: 20, + }, + map: { + flex: 1, + }, +}); -- 2.49.1 From 9c94ec04548443a34fb0a7d631ee60d2661ce25c Mon Sep 17 00:00:00 2001 From: bagasbanuna Date: Mon, 2 Mar 2026 16:34:24 +0800 Subject: [PATCH 3/3] Fix Bug Maps Platform Update - components/Map/MapSelectedPlatform.tsx - components/Map/MapSelectedV2.tsx Maps Screens - screens/Maps/ScreenMapsCreate.tsx - screens/Maps/ScreenMapsEdit.tsx Home - screens/Home/bottomFeatureSection.tsx Config & Native - app.config.js - android/app/build.gradle - ios/HIPMIBadungConnect.xcodeproj/project.pbxproj - ios/HIPMIBadungConnect/Info.plist ### No Issue --- android/app/build.gradle | 4 +- app.config.js | 6 +- components/Map/MapSelectedPlatform.tsx | 32 ++- components/Map/MapSelectedV2.tsx | 204 ++++++++---------- .../project.pbxproj | 108 ++++++++++ ios/HIPMIBadungConnect/Info.plist | 4 +- screens/Home/bottomFeatureSection.tsx | 8 +- screens/Maps/ScreenMapsCreate.tsx | 6 - screens/Maps/ScreenMapsEdit.tsx | 105 ++++----- 9 files changed, 273 insertions(+), 204 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index b761b19..836f4c2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -100,8 +100,8 @@ packagingOptions { applicationId 'com.bip.hipmimobileapp' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 4 - versionName "1.0.1" + versionCode 1 + versionName "1.0.2" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } diff --git a/app.config.js b/app.config.js index 3a7b560..2403739 100644 --- a/app.config.js +++ b/app.config.js @@ -4,7 +4,7 @@ require("dotenv").config(); export default { name: "HIPMI Badung Connect", slug: "hipmi-mobile", - version: "1.0.1", + version: "1.0.2", orientation: "portrait", icon: "./assets/images/icon.png", scheme: "hipmimobile", @@ -21,7 +21,7 @@ export default { "Aplikasi membutuhkan akses lokasi untuk menampilkan peta.", }, associatedDomains: ["applinks:cld-dkr-staging-hipmi.wibudev.com"], - buildNumber: "21", + buildNumber: "1", }, android: { @@ -32,7 +32,7 @@ export default { }, edgeToEdgeEnabled: true, package: "com.bip.hipmimobileapp", - versionCode: 4, + versionCode: 1, // softwareKeyboardLayoutMode: 'resize', // option: untuk mengatur keyboard pada room chst collaboration intentFilters: [ { diff --git a/components/Map/MapSelectedPlatform.tsx b/components/Map/MapSelectedPlatform.tsx index a6d316f..1eae1df 100644 --- a/components/Map/MapSelectedPlatform.tsx +++ b/components/Map/MapSelectedPlatform.tsx @@ -1,7 +1,6 @@ import { Platform } from "react-native"; import MapSelected from "./MapSelected"; -import { MapSelectedV2 } from "./MapSelectedV2"; -import { Region } from "./MapSelectedV2"; +import MapSelectedV2 from "./MapSelectedV2"; import { LatLng } from "react-native-maps"; /** @@ -58,18 +57,18 @@ export function MapSelectedPlatform({ showsMyLocationButton = true, }: MapSelectedPlatformProps) { // iOS: Gunakan react-native-maps - if (Platform.OS === "ios") { - return ( - { - onLocationSelect(location); - }} - height={height} - /> - ); - } + // if (Platform.OS === "ios") { + // return ( + // { + // onLocationSelect(location); + // }} + // height={height} + // /> + // ); + // } // Android: Gunakan MapLibre // Konversi dari LatLng ke [longitude, latitude] jika perlu @@ -81,7 +80,6 @@ export function MapSelectedPlatform({ return ( { // Konversi dari [longitude, latitude] ke LatLng untuk konsistensi @@ -92,8 +90,8 @@ export function MapSelectedPlatform({ onLocationSelect(latLng); }} height={height} - showUserLocation={showUserLocation} - showsMyLocationButton={showsMyLocationButton} + // showUserLocation={showUserLocation} + // showsMyLocationButton={showsMyLocationButton} /> ); } diff --git a/components/Map/MapSelectedV2.tsx b/components/Map/MapSelectedV2.tsx index 286ce4a..432ec1e 100644 --- a/components/Map/MapSelectedV2.tsx +++ b/components/Map/MapSelectedV2.tsx @@ -1,125 +1,119 @@ -import React, { useCallback, useMemo, useRef } from "react"; -import { - StyleSheet, - View, - ViewStyle, - StyleProp, -} from "react-native"; -import { MainColor } from "@/constants/color-palet"; +import React, { useCallback, useRef, useEffect, useState } from "react"; +import { StyleSheet, View, ActivityIndicator } from "react-native"; import { MapView, Camera, PointAnnotation, } from "@maplibre/maplibre-react-native"; +import * as Location from "expo-location"; -const DEFAULT_MAP_STYLE = "https://tiles.openfreemap.org/styles/liberty"; +const MAP_STYLE = "https://tiles.openfreemap.org/styles/liberty"; +const DEBOUNCE_MS = 800; -export interface Region { - latitude: number; - longitude: number; - latitudeDelta: number; - longitudeDelta: number; -} - -export interface MapSelectedV2Props { - initialRegion?: Region; +interface Props { 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, - }), - [], +}: Props) { + const lastTapRef = useRef(0); + const cameraRef = useRef(null); + + const [userLocation, setUserLocation] = useState<[number, number] | null>( + null, ); + const [isLoadingLocation, setIsLoadingLocation] = useState(true); - const region = initialRegion || defaultRegion; + // ✅ Ambil lokasi user saat pertama mount + useEffect(() => { + (async () => { + try { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== "granted") { + console.log("Permission lokasi ditolak"); + setIsLoadingLocation(false); + return; + } - // ✅ Simpan initial center — TIDAK berubah saat user tap - const initialCenter = useRef<[number, number]>([ - region.longitude, - region.latitude, - ]); + const location = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.Balanced, + }); + + const coords: [number, number] = [ + location.coords.longitude, + location.coords.latitude, + ]; + + setUserLocation(coords); + + // ✅ Fly ke posisi user jika belum ada selectedLocation + if (!selectedLocation && cameraRef.current) { + cameraRef.current.flyTo(coords, 1000); + } + } catch (error) { + console.log("Gagal ambil lokasi:", error); + } finally { + setIsLoadingLocation(false); + } + })(); + }, [isLoadingLocation]); const handleMapPress = useCallback( (event: any) => { - const coordinate = event?.geometry?.coordinates || event?.coordinates; - if (coordinate && Array.isArray(coordinate) && coordinate.length === 2) { - console.log("[MapSelectedV2] coordinate", coordinate); - onLocationSelect?.([coordinate[0], coordinate[1]]); - } + const now = Date.now(); + if (now - lastTapRef.current < DEBOUNCE_MS) return; + lastTapRef.current = now; + + const coords = event?.geometry?.coordinates; + if (!coords || coords.length < 2) return; + + onLocationSelect?.([coords[0], coords[1]]); }, [onLocationSelect], ); + // Center awal kamera: + // 1. Jika ada selectedLocation → pakai itu + // 2. Jika ada userLocation → pakai itu + // 3. Fallback → Bali + const initialCenter: [number, number] = selectedLocation ?? + userLocation ?? [115.1756897, -8.737109]; + return ( - + + {/* Loading indicator saat fetch lokasi */} + {isLoadingLocation && ( + + + + )} + - {/* ✅ Camera hanya set sekali di awal, tidak reactive ke selectedLocation */} - {/* ✅ Hanya render PointAnnotation jika ada selectedLocation */} - {/* ✅ Key statis — tidak pernah unmount/remount */} {selectedLocation && ( - + )} @@ -128,37 +122,29 @@ export function MapSelectedV2({ } const styles = StyleSheet.create({ - container: { - width: "100%", - backgroundColor: "#f5f5f5", - overflow: "hidden", - borderRadius: 8, + dot: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: "#0a1f44", + borderWidth: 2, + borderColor: "#fff", }, - map: { - flex: 1, - }, - markerContainer: { - width: 40, - height: 40, - alignItems: "center", - justifyContent: "center", - }, - // ✅ Ring statis pengganti pulse animation - markerRing: { + loadingOverlay: { position: "absolute", - width: 36, - height: 36, - borderRadius: 18, - borderWidth: 2, - opacity: 0.4, - }, - markerDot: { - width: 16, - height: 16, - borderRadius: 8, - borderWidth: 2, - borderColor: "#FFFFFF", + top: 10, + alignSelf: "center", + zIndex: 10, + backgroundColor: "#fff", + borderRadius: 20, + paddingHorizontal: 12, + paddingVertical: 6, + elevation: 4, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.15, + shadowRadius: 4, }, }); -export default MapSelectedV2; \ No newline at end of file +export default MapSelectedV2; diff --git a/ios/HIPMIBadungConnect.xcodeproj/project.pbxproj b/ios/HIPMIBadungConnect.xcodeproj/project.pbxproj index b64e9e3..18e7185 100644 --- a/ios/HIPMIBadungConnect.xcodeproj/project.pbxproj +++ b/ios/HIPMIBadungConnect.xcodeproj/project.pbxproj @@ -165,6 +165,12 @@ CCCF75FD0B87410193A6B7DB /* Remove signature files (Xcode workaround) */, 51F61F14096F4B7FBD9344A7 /* Remove signature files (Xcode workaround) */, 60F3AE3AC4B24C2FA7FC8F10 /* Remove signature files (Xcode workaround) */, + E188CB171C1B4A4DA64BC5C4 /* Remove signature files (Xcode workaround) */, + 90714A8C562E4676B84E6E07 /* Remove signature files (Xcode workaround) */, + DB93AB500BC2459E8BAE3F74 /* Remove signature files (Xcode workaround) */, + EEC6AC8AF9C04E91AA81C190 /* Remove signature files (Xcode workaround) */, + D2BED766D85C4781B154BD69 /* Remove signature files (Xcode workaround) */, + E01278D305D540D5B29ED50A /* Remove signature files (Xcode workaround) */, ); buildRules = ( ); @@ -671,6 +677,108 @@ rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\"; "; }; + E188CB171C1B4A4DA64BC5C4 /* 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\"; + "; + }; + 90714A8C562E4676B84E6E07 /* 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\"; + "; + }; + DB93AB500BC2459E8BAE3F74 /* 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\"; + "; + }; + EEC6AC8AF9C04E91AA81C190 /* 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\"; + "; + }; + D2BED766D85C4781B154BD69 /* 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\"; + "; + }; + E01278D305D540D5B29ED50A /* 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/ios/HIPMIBadungConnect/Info.plist b/ios/HIPMIBadungConnect/Info.plist index 9f0f327..a743c17 100644 --- a/ios/HIPMIBadungConnect/Info.plist +++ b/ios/HIPMIBadungConnect/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.1 + 1.0.2 CFBundleSignature ???? CFBundleURLTypes @@ -39,7 +39,7 @@ CFBundleVersion - 21 + 1 ITSAppUsesNonExemptEncryption LSMinimumSystemVersion diff --git a/screens/Home/bottomFeatureSection.tsx b/screens/Home/bottomFeatureSection.tsx index 9e56f64..23bde4b 100644 --- a/screens/Home/bottomFeatureSection.tsx +++ b/screens/Home/bottomFeatureSection.tsx @@ -9,7 +9,7 @@ import { apiJobGetAll } from "@/service/api-client/api-job"; import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom"; export default function Home_BottomFeatureSection() { - const [listData, setListData] = useState([]); + const [listData, setListData] = useState(null); const onLoadData = async () => { try { @@ -17,7 +17,7 @@ export default function Home_BottomFeatureSection() { category: "beranda", }); - // console.log("[DATA JOB]", JSON.stringify(response.data, null, 2)); + console.log("[DATA JOB]", JSON.stringify(response.data, null, 2)); const result = response.data .sort( (a: any, b: any) => @@ -36,7 +36,7 @@ export default function Home_BottomFeatureSection() { }, []) ); - if (!listData || listData.length === 0) { + if (listData === null) { return } @@ -54,7 +54,7 @@ export default function Home_BottomFeatureSection() { {/* Vacancy Item 1 */} - {listData.map((item: any, index: number) => ( + {listData?.map((item: any, index: number) => ( diff --git a/screens/Maps/ScreenMapsCreate.tsx b/screens/Maps/ScreenMapsCreate.tsx index f6b345d..33f5dfb 100644 --- a/screens/Maps/ScreenMapsCreate.tsx +++ b/screens/Maps/ScreenMapsCreate.tsx @@ -20,7 +20,6 @@ import { router, useLocalSearchParams } from "expo-router"; import { useState } from "react"; import { LatLng } from "react-native-maps"; import Toast from "react-native-toast-message"; -import MapSelected from "@/components/Map/MapSelected"; /** * Screen untuk create maps @@ -150,14 +149,9 @@ export function Maps_ScreenMapsCreate() { - {/* */} { - // Set location (auto handle LatLng format) setSelectedLocation(location as LatLng); }} height={300} diff --git a/screens/Maps/ScreenMapsEdit.tsx b/screens/Maps/ScreenMapsEdit.tsx index c451815..deee80f 100644 --- a/screens/Maps/ScreenMapsEdit.tsx +++ b/screens/Maps/ScreenMapsEdit.tsx @@ -9,6 +9,9 @@ import { TextInputCustom, ViewWrapper, } from "@/components"; +import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom"; +import { MapSelectedPlatform } from "@/components/Map/MapSelectedPlatform"; +import MapSelectedV2 from "@/components/Map/MapSelectedV2"; import API_IMAGE from "@/constants/api-storage"; import DIRECTORY_ID from "@/constants/directory-id"; import { apiMapsGetOne, apiMapsUpdate } from "@/service/api-client/api-maps"; @@ -16,8 +19,7 @@ import { uploadFileService } from "@/service/upload-service"; import pickFile, { IFileData } from "@/utils/pickFile"; import { router, useFocusEffect, useLocalSearchParams } from "expo-router"; import { useCallback, useState } from "react"; -import { StyleSheet, View } from "react-native"; -import MapView, { LatLng, Marker } from "react-native-maps"; +import { LatLng } from "react-native-maps"; import Toast from "react-native-toast-message"; const defaultRegion = { @@ -43,7 +45,7 @@ export function Maps_ScreenMapsEdit() { useFocusEffect( useCallback(() => { onLoadData(); - }, [id]) + }, [id]), ); const onLoadData = async () => { @@ -64,10 +66,14 @@ export function Maps_ScreenMapsEdit() { } }; - const handleMapPress = (event: any) => { - const { latitude, longitude } = event.nativeEvent.coordinate; - const location = { latitude, longitude }; - setSelectedLocation(location); + const handleLocationSelect = (location: LatLng | [number, number]) => { + if (Array.isArray(location)) { + // Android format: [longitude, latitude] + setSelectedLocation({ latitude: location[1], longitude: location[0] }); + } else { + // iOS format: LatLng + setSelectedLocation(location); + } }; const handleSubmit = async () => { @@ -149,51 +155,41 @@ export function Maps_ScreenMapsEdit() { ); + const initialRegion = + data?.latitude && data?.longitude + ? { + latitude: Number(data?.latitude), + longitude: Number(data?.longitude), + latitudeDelta: 0.1, + longitudeDelta: 0.1, + } + : defaultRegion; + return ( - - - {selectedLocation ? ( - - ) : ( - - )} - - + {/* */} + + {!data || !data.latitude || !data.longitude ? ( + + ) : ( + { + setData({ + ...data, + longitude: location[0], + latitude: location[1], + }); + }} + /> + )} ); } - -const styles = StyleSheet.create({ - container: { - width: "100%", - backgroundColor: "#f5f5f5", - overflow: "hidden", - borderRadius: 8, - marginBottom: 20, - }, - map: { - flex: 1, - }, -}); -- 2.49.1