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
This commit is contained in:
2026-02-27 16:57:01 +08:00
parent 67070bb2f1
commit f5d09a2906
7 changed files with 542 additions and 144 deletions

33
QWEN.md
View File

@@ -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
<PointAnnotation
coordinate={annotationCoordinate} // Always rendered
...
>
<View style={{ opacity: selectedLocation ? 1 : 0 }}>
<SelectedLocationMarker />
</View>
</PointAnnotation>
// ❌ BAD: Conditional rendering causes crash
{selectedLocation && (
<PointAnnotation coordinate={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)

View File

@@ -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<LatLng | null>(null);
const [name, setName] = useState<string>("");
const [image, setImage] = useState<IFileData | null>(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 = (
<BoxButtonOnFooter>
<ButtonCustom
isLoading={isLoading}
disabled={!selectedLocation || name === ""}
onPress={handleSubmit}
>
Simpan
</ButtonCustom>
</BoxButtonOnFooter>
);
return (
<ViewWrapper footerComponent={buttonFooter}>
<InformationBox text="Tentukan lokasi pin map dengan menekan pada map." />
<BaseBox>
<MapSelected
selectedLocation={selectedLocation as any}
setSelectedLocation={setSelectedLocation}
/>
</BaseBox>
<TextInputCustom
required
label="Nama Pin"
placeholder="Masukkan nama pin maps"
value={name}
onChangeText={setName}
/>
<Spacing height={50} />
<InformationBox text="Upload foto lokasi bisnis anda untuk ditampilkan dalam detail maps." />
<LandscapeFrameUploaded image={image?.uri} />
<ButtonCenteredOnly
icon="upload"
onPress={() => {
pickFile({
allowedType: "image",
setImageUri(file) {
setImage(file);
},
});
}}
>
Upload
</ButtonCenteredOnly>
<Spacing height={50} />
</ViewWrapper>
<>
<Maps_ScreenMapsCreate />
</>
);
}

View File

@@ -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
* <MapSelectedPlatform
* selectedLocation={selectedLocation}
* onLocationSelect={setSelectedLocation}
* height={300}
* />
* ```
*/
export function MapSelectedPlatform({
initialRegion,
selectedLocation,
onLocationSelect,
height = 400,
showUserLocation = true,
showsMyLocationButton = true,
}: MapSelectedPlatformProps) {
// iOS: Gunakan react-native-maps
if (Platform.OS === "ios") {
return (
<MapSelected
initialRegion={initialRegion}
selectedLocation={(selectedLocation as LatLng) || { latitude: 0, longitude: 0 }}
setSelectedLocation={(location: LatLng) => {
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 (
<MapSelectedV2
initialRegion={initialRegion as Region}
selectedLocation={androidLocation}
onLocationSelect={(location: [number, number]) => {
// 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;

View File

@@ -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<ViewStyle>;
mapViewStyle?: StyleProp<ViewStyle>;
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 (
<View style={styles.markerContainer}>
<View style={[styles.markerRing, { borderColor: color }]} />
<View style={[styles.markerDot, { backgroundColor: color }]} />
</View>
);
}
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 (
<View style={[style, { height }]} collapsable={false}>
<MapView
style={mapViewStyle}
mapStyle={mapStyle || DEFAULT_MAP_STYLE}
onPress={handleMapPress}
logoEnabled={false}
compassEnabled={true}
compassViewPosition={2}
compassViewMargins={{ x: 10, y: 10 }}
scrollEnabled={true}
zoomEnabled={true}
rotateEnabled={true}
pitchEnabled={false}
>
{/* ✅ Camera hanya set sekali di awal, tidak reactive ke selectedLocation */}
<Camera
defaultSettings={{
centerCoordinate: initialCenter.current,
zoomLevel: zoomLevel,
}}
/>
{/* ✅ Hanya render PointAnnotation jika ada selectedLocation */}
{/* ✅ Key statis — tidak pernah unmount/remount */}
{selectedLocation && (
<PointAnnotation
id="selected-location"
key="selected-location"
coordinate={selectedLocation}
>
<SelectedLocationMarker />
</PointAnnotation>
)}
</MapView>
</View>
);
}
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;

View File

@@ -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 */

View File

@@ -40,8 +40,12 @@ export default function DrawerMaps({
closeDrawer={() => setOpenDrawer(false)}
height={"auto"}
>
<DummyLandscapeImage height={200} imageId={selected.imageId} />
<Spacing />
{selected.imageId && (
<>
<DummyLandscapeImage height={200} imageId={selected.imageId} />
<Spacing />
</>
)}
<StackCustom gap={"xs"}>
<GridTwoView
spanLeft={2}

View File

@@ -0,0 +1,192 @@
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import {
BoxButtonOnFooter,
ButtonCustom,
InformationBox,
BaseBox,
Spacing,
TextInputCustom,
LandscapeFrameUploaded,
ButtonCenteredOnly,
} from "@/components";
import { MapSelectedPlatform } from "@/components/Map/MapSelectedPlatform";
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 { IFileData } from "@/utils/pickFile";
import pickFile 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 MapSelected from "@/components/Map/MapSelected";
/**
* Screen untuk create maps
*
* Fitur:
* - Pilih lokasi dari map
* - Input nama pin
* - Upload foto lokasi
* - Submit data maps
*/
export function Maps_ScreenMapsCreate() {
const { user } = useAuth();
const { id } = useLocalSearchParams();
// State management
// Format: LatLng { latitude, longitude } untuk konsistensi
const [selectedLocation, setSelectedLocation] = useState<LatLng | null>(null);
const [name, setName] = useState<string>("");
const [image, setImage] = useState<IFileData | null>(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 = (
<BoxButtonOnFooter>
<ButtonCustom
isLoading={isLoading}
disabled={!selectedLocation || name === ""}
onPress={handleSubmit}
>
Simpan
</ButtonCustom>
</BoxButtonOnFooter>
);
/**
* Render screen dengan NewWrapper
*/
return (
<NewWrapper footerComponent={buttonFooter}>
<InformationBox text="Tentukan lokasi pin map dengan menekan pada map." />
<BaseBox>
{/* <MapSelected
selectedLocation={selectedLocation as any}
setSelectedLocation={setSelectedLocation}
/> */}
<MapSelectedPlatform
selectedLocation={selectedLocation}
onLocationSelect={(location) => {
// Set location (auto handle LatLng format)
setSelectedLocation(location as LatLng);
}}
height={300}
showUserLocation={true}
showsMyLocationButton={true}
/>
</BaseBox>
<TextInputCustom
required
label="Nama Pin"
placeholder="Masukkan nama pin maps"
value={name}
onChangeText={setName}
/>
<Spacing height={50} />
<InformationBox text="Upload foto lokasi bisnis anda untuk ditampilkan dalam detail maps." />
<LandscapeFrameUploaded image={image?.uri} />
<ButtonCenteredOnly icon="upload" onPress={handleImageUpload}>
Upload
</ButtonCenteredOnly>
<Spacing height={50} />
</NewWrapper>
);
}
export default Maps_ScreenMapsCreate;