Compare commits
5 Commits
fix-maps/2
...
fix-maps/2
| Author | SHA1 | Date | |
|---|---|---|---|
| f5d09a2906 | |||
| 67070bb2f1 | |||
| fb19ec60b2 | |||
| e8f5c5b174 | |||
| 74a4d88277 |
33
QWEN.md
33
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
|
- **Architecture**: File-based routing with Expo Router
|
||||||
- **State Management**: Context API (AuthContext)
|
- **State Management**: Context API (AuthContext)
|
||||||
- **UI Components**: React Native Paper, custom components
|
- **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
|
- **Push Notifications**: React Native Firebase Messaging
|
||||||
- **Build System**: Metro bundler
|
- **Build System**: Metro bundler
|
||||||
- **Package Manager**: Bun
|
- **Package Manager**: Bun
|
||||||
@@ -381,8 +381,8 @@ apiConfig.interceptors.request.use(async (config) => {
|
|||||||
- Push Notifications (FCM)
|
- Push Notifications (FCM)
|
||||||
- Configured for both iOS and Android
|
- Configured for both iOS and Android
|
||||||
|
|
||||||
### Mapbox
|
### Maplibre
|
||||||
- Map integration via `@rnmapbox/maps`
|
- Map integration via `@maplibre/maplibre-react-native`
|
||||||
- Location permissions configured
|
- Location permissions configured
|
||||||
|
|
||||||
### Deep Linking
|
### Deep Linking
|
||||||
@@ -475,10 +475,34 @@ rm -rf node_modules bun.lock
|
|||||||
bun install
|
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
|
## Documentation Files
|
||||||
|
|
||||||
- `docs/CHANGE_LOG.md` - Change log for recent updates
|
- `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/hipmi-note.md` - Build and deployment notes
|
||||||
- `docs/prompt-for-qwen-code.md` - Development prompts and patterns
|
- `docs/prompt-for-qwen-code.md` - Development prompts and patterns
|
||||||
|
|
||||||
@@ -488,3 +512,4 @@ bun install
|
|||||||
- [React Native Documentation](https://reactnative.dev/)
|
- [React Native Documentation](https://reactnative.dev/)
|
||||||
- [Expo Router Documentation](https://docs.expo.dev/router/introduction/)
|
- [Expo Router Documentation](https://docs.expo.dev/router/introduction/)
|
||||||
- [TypeScript Documentation](https://www.typescriptlang.org/docs/)
|
- [TypeScript Documentation](https://www.typescriptlang.org/docs/)
|
||||||
|
- [Maplibre React Native](https://github.com/maplibre/maplibre-react-native)
|
||||||
|
|||||||
@@ -1,143 +1,9 @@
|
|||||||
import {
|
import Maps_ScreenMapsCreate from "@/screens/Maps/ScreenMapsCreate";
|
||||||
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";
|
|
||||||
|
|
||||||
export default function MapsCreate() {
|
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 (
|
return (
|
||||||
<ViewWrapper footerComponent={buttonFooter}>
|
<>
|
||||||
<InformationBox text="Tentukan lokasi pin map dengan menekan pada map." />
|
<Maps_ScreenMapsCreate />
|
||||||
|
</>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import { BackButton } from "@/components";
|
|
||||||
import MapsView from "@/screens/Maps/MapsView";
|
|
||||||
import MapsView2 from "@/screens/Maps/MapsView2";
|
import MapsView2 from "@/screens/Maps/MapsView2";
|
||||||
import { Stack } from "expo-router";
|
|
||||||
import { Platform, Text, View } from "react-native";
|
|
||||||
|
|
||||||
export interface LocationItem {
|
export interface LocationItem {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@@ -21,8 +17,8 @@ export default function Maps() {
|
|||||||
headerLeft: () => <BackButton />,
|
headerLeft: () => <BackButton />,
|
||||||
}}
|
}}
|
||||||
/> */}
|
/> */}
|
||||||
{Platform.OS === "ios" ? <MapsView /> : <MapsView2 />}
|
{/* {Platform.OS === "ios" ? <MapsView /> : <MapsView2 />} */}
|
||||||
{/* <MapsView2 /> */}
|
<MapsView2 />
|
||||||
{/* <View style={{ flex: 1, backgroundColor: "gray" }}><Text style={{ color: "white" }}>Map disabled</Text></View> */}
|
{/* <View style={{ flex: 1, backgroundColor: "gray" }}><Text style={{ color: "white" }}>Map disabled</Text></View> */}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
112
components/Map/MapSelectedPlatform.tsx
Normal file
112
components/Map/MapSelectedPlatform.tsx
Normal 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;
|
||||||
163
components/Map/MapSelectedV2.tsx
Normal file
163
components/Map/MapSelectedV2.tsx
Normal 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;
|
||||||
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
101
docs/PODS.back
Normal file
101
docs/PODS.back
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
NOTE:
|
||||||
|
|
||||||
|
Untuk Development Selanjutnya:
|
||||||
|
Sekarang Anda bisa menjalankan:
|
||||||
|
|
||||||
|
1 # Untuk run iOS dev client
|
||||||
|
2 bun run ios
|
||||||
|
3
|
||||||
|
4 # Atau dengan Expo
|
||||||
|
5 bunx expo run:ios
|
||||||
|
|
||||||
|
Jika di masa depan terjadi error serupa, Anda bisa gunakan command ini:
|
||||||
|
|
||||||
|
1 cd ios
|
||||||
|
2 rm -rf Pods Podfile.lock
|
||||||
|
3 pod install
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
use_modular_headers!
|
||||||
|
|
||||||
|
require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
|
||||||
|
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
|
||||||
|
|
||||||
|
require 'json'
|
||||||
|
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
|
||||||
|
|
||||||
|
ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'
|
||||||
|
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
|
||||||
|
ENV['RCT_USE_RN_DEP'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
|
||||||
|
ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
|
||||||
|
platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
|
||||||
|
|
||||||
|
prepare_react_native_project!
|
||||||
|
|
||||||
|
target 'HIPMIBadungConnect' do
|
||||||
|
use_expo_modules!
|
||||||
|
|
||||||
|
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
|
||||||
|
config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
|
||||||
|
else
|
||||||
|
config_command = [
|
||||||
|
'npx',
|
||||||
|
'expo-modules-autolinking',
|
||||||
|
'react-native-config',
|
||||||
|
'--json',
|
||||||
|
'--platform',
|
||||||
|
'ios'
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
config = use_native_modules!(config_command)
|
||||||
|
|
||||||
|
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
|
||||||
|
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
|
||||||
|
|
||||||
|
|
||||||
|
use_react_native!(
|
||||||
|
:path => config[:reactNativePath],
|
||||||
|
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
|
||||||
|
# An absolute path to your application root.
|
||||||
|
:app_path => "#{Pod::Config.instance.installation_root}/..",
|
||||||
|
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
|
||||||
|
)
|
||||||
|
|
||||||
|
pod 'Firebase'
|
||||||
|
pod 'Firebase/Messaging'
|
||||||
|
|
||||||
|
# @generated begin post_installer - expo prebuild (DO NOT MODIFY) sync-4092f82b887b5b9edb84642c2a56984d69b9a403
|
||||||
|
post_install do |installer|
|
||||||
|
# @generated begin @maplibre/maplibre-react-native:post-install - expo prebuild (DO NOT MODIFY) sync-6e76c80af0d70c0003d06822dd59b7c729fca472
|
||||||
|
$MLRN.post_install(installer)
|
||||||
|
# @generated end @maplibre/maplibre-react-native:post-install
|
||||||
|
|
||||||
|
# Fix all script phases with incorrect paths
|
||||||
|
installer.pods_project.targets.each do |target|
|
||||||
|
target.build_phases.each do |phase|
|
||||||
|
next unless phase.respond_to?(:shell_script)
|
||||||
|
|
||||||
|
# Fix duplicated path issue
|
||||||
|
if phase.shell_script.include?('with-environment.sh')
|
||||||
|
# Remove any existing path and use proper relative path
|
||||||
|
phase.shell_script = phase.shell_script.gsub(
|
||||||
|
%r{(/.*?/node_modules/react-native)+/scripts/xcode/with-environment.sh},
|
||||||
|
'${PODS_ROOT}/../../node_modules/react-native/scripts/xcode/with-environment.sh'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Standard React Native post install
|
||||||
|
react_native_post_install(
|
||||||
|
installer,
|
||||||
|
config[:reactNativePath],
|
||||||
|
:mac_catalyst_enabled => false,
|
||||||
|
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
|
||||||
|
)
|
||||||
|
end
|
||||||
|
# @generated end post_installer
|
||||||
|
|
||||||
|
end
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "e70d3525c8e2819a8b34f22909815dab5c700c25a06c32388f3930f7b3627768",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "maplibre-gl-native-distribution",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "c68c970ff3ece56cfc3b36849db70167fa208beb",
|
||||||
|
"version" : "6.17.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
969
ios/Podfile.lock
969
ios/Podfile.lock
File diff suppressed because it is too large
Load Diff
129
screens/Maps/DrawerMaps.tsx
Normal file
129
screens/Maps/DrawerMaps.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import {
|
||||||
|
DrawerCustom,
|
||||||
|
DummyLandscapeImage,
|
||||||
|
Spacing,
|
||||||
|
StackCustom,
|
||||||
|
TextCustom,
|
||||||
|
Grid,
|
||||||
|
ButtonCustom,
|
||||||
|
} from "@/components";
|
||||||
|
import GridTwoView from "@/components/_ShareComponent/GridTwoView";
|
||||||
|
import { ICON_SIZE_SMALL } from "@/constants/constans-value";
|
||||||
|
import { openInDeviceMaps } from "@/utils/openInDeviceMaps";
|
||||||
|
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
|
||||||
|
interface TypeDrawerMaps {
|
||||||
|
openDrawer: boolean;
|
||||||
|
setOpenDrawer: (value: boolean) => void;
|
||||||
|
selected: {
|
||||||
|
id: string;
|
||||||
|
bidangBisnis: string;
|
||||||
|
nomorTelepon: string;
|
||||||
|
alamatBisnis: string;
|
||||||
|
namePin: string;
|
||||||
|
imageId: string;
|
||||||
|
portofolioId: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DrawerMaps({
|
||||||
|
openDrawer,
|
||||||
|
setOpenDrawer,
|
||||||
|
selected,
|
||||||
|
}: TypeDrawerMaps) {
|
||||||
|
return (
|
||||||
|
<DrawerCustom
|
||||||
|
isVisible={openDrawer}
|
||||||
|
closeDrawer={() => setOpenDrawer(false)}
|
||||||
|
height={"auto"}
|
||||||
|
>
|
||||||
|
{selected.imageId && (
|
||||||
|
<>
|
||||||
|
<DummyLandscapeImage height={200} imageId={selected.imageId} />
|
||||||
|
<Spacing />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<StackCustom gap={"xs"}>
|
||||||
|
<GridTwoView
|
||||||
|
spanLeft={2}
|
||||||
|
spanRight={10}
|
||||||
|
leftItem={
|
||||||
|
<FontAwesome
|
||||||
|
name="building-o"
|
||||||
|
size={ICON_SIZE_SMALL}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
rightItem={<TextCustom>{selected.namePin}</TextCustom>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GridTwoView
|
||||||
|
spanLeft={2}
|
||||||
|
spanRight={10}
|
||||||
|
leftItem={
|
||||||
|
<Ionicons
|
||||||
|
name="list-outline"
|
||||||
|
size={ICON_SIZE_SMALL}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
rightItem={<TextCustom>{selected.bidangBisnis}</TextCustom>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GridTwoView
|
||||||
|
spanLeft={2}
|
||||||
|
spanRight={10}
|
||||||
|
leftItem={
|
||||||
|
<Ionicons
|
||||||
|
name="call-outline"
|
||||||
|
size={ICON_SIZE_SMALL}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
rightItem={<TextCustom>{selected.nomorTelepon}</TextCustom>}
|
||||||
|
/>
|
||||||
|
<GridTwoView
|
||||||
|
spanLeft={2}
|
||||||
|
spanRight={10}
|
||||||
|
leftItem={
|
||||||
|
<Ionicons
|
||||||
|
name="location-outline"
|
||||||
|
size={ICON_SIZE_SMALL}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
rightItem={<TextCustom>{selected.alamatBisnis}</TextCustom>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={6} style={{ paddingRight: 10 }}>
|
||||||
|
<ButtonCustom
|
||||||
|
onPress={() => {
|
||||||
|
setOpenDrawer(false);
|
||||||
|
router.push(`/portofolio/${selected.portofolioId}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Detail
|
||||||
|
</ButtonCustom>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={6} style={{ paddingLeft: 10 }}>
|
||||||
|
<ButtonCustom
|
||||||
|
onPress={() => {
|
||||||
|
openInDeviceMaps({
|
||||||
|
latitude: selected.latitude,
|
||||||
|
longitude: selected.longitude,
|
||||||
|
title: selected.namePin,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Buka Maps
|
||||||
|
</ButtonCustom>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</StackCustom>
|
||||||
|
</DrawerCustom>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,13 @@
|
|||||||
import { useCallback, useState } from "react";
|
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 { apiMapsGetAll } from "@/service/api-client/api-maps";
|
||||||
import {
|
import { useFocusEffect } from "expo-router";
|
||||||
Camera,
|
|
||||||
MapView,
|
|
||||||
PointAnnotation,
|
|
||||||
} from "@maplibre/maplibre-react-native";
|
|
||||||
import { router, useFocusEffect } from "expo-router";
|
|
||||||
import {
|
|
||||||
DrawerCustom,
|
|
||||||
DummyLandscapeImage,
|
|
||||||
Spacing,
|
|
||||||
StackCustom,
|
|
||||||
TextCustom,
|
|
||||||
Grid,
|
|
||||||
ButtonCustom,
|
|
||||||
} from "@/components";
|
|
||||||
import GridTwoView from "@/components/_ShareComponent/GridTwoView";
|
|
||||||
import { ICON_SIZE_SMALL } from "@/constants/constans-value";
|
|
||||||
import { openInDeviceMaps } from "@/utils/openInDeviceMaps";
|
|
||||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
|
||||||
|
|
||||||
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 {
|
interface TypeMaps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -52,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() {
|
export default function MapsView2() {
|
||||||
const [list, setList] = useState<TypeMaps[] | null>(null);
|
const [list, setList] = useState<TypeMaps[] | null>(null);
|
||||||
const [loadList, setLoadList] = useState(false);
|
const [loadList, setLoadList] = useState(false);
|
||||||
@@ -88,7 +62,6 @@ export default function MapsView2() {
|
|||||||
const response = await apiMapsGetAll();
|
const response = await apiMapsGetAll();
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
// console.log("[RESPONSE]", JSON.stringify(response.data, null, 2));
|
|
||||||
setList(response.data);
|
setList(response.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -98,162 +71,45 @@ 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={styles.container}>
|
<ViewWrapper>
|
||||||
<MapView style={styles.map} mapStyle={MAP_STYLE}>
|
<MapsV2Custom markers={markers} />
|
||||||
<Camera
|
</ViewWrapper>
|
||||||
zoomLevel={12}
|
|
||||||
centerCoordinate={[defaultRegion.longitude, defaultRegion.latitude]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{list?.map((item: TypeMaps) => {
|
<DrawerMaps
|
||||||
const imageUrl = API_IMAGE.GET({ fileId: item.Portofolio.logoId });
|
openDrawer={openDrawer}
|
||||||
|
setOpenDrawer={setOpenDrawer}
|
||||||
return (
|
selected={selected}
|
||||||
<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>
|
|
||||||
|
|
||||||
<DrawerCustom
|
|
||||||
isVisible={openDrawer}
|
|
||||||
closeDrawer={() => setOpenDrawer(false)}
|
|
||||||
height={"auto"}
|
|
||||||
>
|
|
||||||
<DummyLandscapeImage height={200} imageId={selected.imageId} />
|
|
||||||
<Spacing />
|
|
||||||
<StackCustom gap={"xs"}>
|
|
||||||
<GridTwoView
|
|
||||||
spanLeft={2}
|
|
||||||
spanRight={10}
|
|
||||||
leftItem={
|
|
||||||
<FontAwesome
|
|
||||||
name="building-o"
|
|
||||||
size={ICON_SIZE_SMALL}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
rightItem={<TextCustom>{selected.namePin}</TextCustom>}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<GridTwoView
|
|
||||||
spanLeft={2}
|
|
||||||
spanRight={10}
|
|
||||||
leftItem={
|
|
||||||
<Ionicons
|
|
||||||
name="list-outline"
|
|
||||||
size={ICON_SIZE_SMALL}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
rightItem={<TextCustom>{selected.bidangBisnis}</TextCustom>}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<GridTwoView
|
|
||||||
spanLeft={2}
|
|
||||||
spanRight={10}
|
|
||||||
leftItem={
|
|
||||||
<Ionicons
|
|
||||||
name="call-outline"
|
|
||||||
size={ICON_SIZE_SMALL}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
rightItem={<TextCustom>{selected.nomorTelepon}</TextCustom>}
|
|
||||||
/>
|
|
||||||
<GridTwoView
|
|
||||||
spanLeft={2}
|
|
||||||
spanRight={10}
|
|
||||||
leftItem={
|
|
||||||
<Ionicons
|
|
||||||
name="location-outline"
|
|
||||||
size={ICON_SIZE_SMALL}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
rightItem={<TextCustom>{selected.alamatBisnis}</TextCustom>}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Grid>
|
|
||||||
<Grid.Col span={6} style={{ paddingRight: 10 }}>
|
|
||||||
<ButtonCustom
|
|
||||||
onPress={() => {
|
|
||||||
setOpenDrawer(false);
|
|
||||||
router.push(`/portofolio/${selected.portofolioId}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Detail
|
|
||||||
</ButtonCustom>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6} style={{ paddingLeft: 10 }}>
|
|
||||||
<ButtonCustom
|
|
||||||
onPress={() => {
|
|
||||||
openInDeviceMaps({
|
|
||||||
latitude: selected.latitude,
|
|
||||||
longitude: selected.longitude,
|
|
||||||
title: selected.namePin,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Buka Maps
|
|
||||||
</ButtonCustom>
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
</StackCustom>
|
|
||||||
</DrawerCustom>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1 },
|
map: {
|
||||||
map: { flex: 1 },
|
flex: 1,
|
||||||
markerContainer: {
|
width: "50%",
|
||||||
width: 30,
|
maxHeight: "50%",
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
markerImage: {
|
});
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
192
screens/Maps/ScreenMapsCreate.tsx
Normal file
192
screens/Maps/ScreenMapsCreate.tsx
Normal 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;
|
||||||
@@ -1,19 +1,42 @@
|
|||||||
import {
|
import { StyleSheet, View } from "react-native";
|
||||||
BaseBox,
|
|
||||||
MapCustom,
|
import { BaseBox, StackCustom, TextCustom } from "@/components";
|
||||||
StackCustom,
|
import { MapsV2Custom } from "@/components/Map/MapsV2Custom";
|
||||||
TextCustom
|
|
||||||
} from "@/components";
|
|
||||||
|
|
||||||
export default function Portofolio_BusinessLocation({
|
export default function Portofolio_BusinessLocation({
|
||||||
data,
|
data,
|
||||||
imageId,
|
imageId,
|
||||||
setOpenDrawerLocation,
|
setOpenDrawerLocation,
|
||||||
}: {
|
}: {
|
||||||
data: any;
|
data: {
|
||||||
|
id: string;
|
||||||
|
imageId: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
namePin: string;
|
||||||
|
pinId: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
imageId?: string;
|
imageId?: string;
|
||||||
setOpenDrawerLocation: (value: boolean) => void;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseBox style={{ height: !data ? 200 : "auto" }}>
|
<BaseBox style={{ height: !data ? 200 : "auto" }}>
|
||||||
@@ -30,18 +53,33 @@ export default function Portofolio_BusinessLocation({
|
|||||||
Lokasi bisnis belum ditambahkan
|
Lokasi bisnis belum ditambahkan
|
||||||
</TextCustom>
|
</TextCustom>
|
||||||
) : (
|
) : (
|
||||||
<MapCustom
|
<View style={styles.mapContainer}>
|
||||||
latitude={data?.latitude}
|
<MapsV2Custom
|
||||||
longitude={data?.longitude}
|
markers={markers}
|
||||||
namePin={data?.namePin}
|
zoomLevel={15}
|
||||||
imageId={imageId}
|
showDefaultMarkers={true}
|
||||||
onPress={() => {
|
markerSize={35}
|
||||||
setOpenDrawerLocation(true);
|
initialRegion={{
|
||||||
}}
|
latitude: data?.latitude,
|
||||||
/>
|
longitude: data?.longitude,
|
||||||
|
latitudeDelta: 0.1,
|
||||||
|
longitudeDelta: 0.1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</StackCustom>
|
</StackCustom>
|
||||||
</BaseBox>
|
</BaseBox>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
mapContainer: {
|
||||||
|
width: "100%",
|
||||||
|
height: 250,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user