Compare commits

...

5 Commits

Author SHA1 Message Date
f5d09a2906 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
2026-02-27 16:57:01 +08:00
67070bb2f1 Fix 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
2026-02-26 18:04:45 +08:00
fb19ec60b2 Fix 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
2026-02-26 17:53:23 +08:00
e8f5c5b174 User Maps
- app/(application)/(user)/maps/index.tsx
- screens/Maps/MapsView2.tsx

New Maps Component
- screens/Maps/DrawerMaps.tsx

Docs / Backup
- docs/PODS.back

### No Issue
2026-02-25 16:46:37 +08:00
74a4d88277 Fix POD File & Maps
User Maps
- app/(application)/(user)/maps/index.tsx
- screens/Maps/MapsView2.tsx

iOS
- HIPMIBadungConnect.xcodeproj/project.pbxproj
- Podfile.lock
- HIPMIBadungConnect.xcworkspace/xcshareddata/

New Maps Component
- screens/Maps/DrawerMaps.tsx

Docs / Backup
- docs/PODS.back

### No Issue
2026-02-25 16:37:42 +08:00
15 changed files with 2386 additions and 873 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

@@ -1,8 +1,4 @@
import { BackButton } from "@/components";
import MapsView from "@/screens/Maps/MapsView";
import MapsView2 from "@/screens/Maps/MapsView2";
import { Stack } from "expo-router";
import { Platform, Text, View } from "react-native";
export interface LocationItem {
id: string | number;
@@ -21,8 +17,8 @@ export default function Maps() {
headerLeft: () => <BackButton />,
}}
/> */}
{Platform.OS === "ios" ? <MapsView /> : <MapsView2 />}
{/* <MapsView2 /> */}
{/* {Platform.OS === "ios" ? <MapsView /> : <MapsView2 />} */}
<MapsView2 />
{/* <View style={{ flex: 1, backgroundColor: "gray" }}><Text style={{ color: "white" }}>Map disabled</Text></View> */}
</>
);

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

@@ -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,
},
});

View 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
View 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

View File

@@ -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
}

File diff suppressed because it is too large Load Diff

129
screens/Maps/DrawerMaps.tsx Normal file
View 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>
);
}

View File

@@ -1,31 +1,13 @@
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 {
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";
import { useFocusEffect } from "expo-router";
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 {
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() {
const [list, setList] = useState<TypeMaps[] | null>(null);
const [loadList, setLoadList] = useState(false);
@@ -88,7 +62,6 @@ export default function MapsView2() {
const response = await apiMapsGetAll();
if (response.success) {
// console.log("[RESPONSE]", JSON.stringify(response.data, null, 2));
setList(response.data);
}
} 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 (
<>
<View style={styles.container}>
<MapView style={styles.map} mapStyle={MAP_STYLE}>
<Camera
zoomLevel={12}
centerCoordinate={[defaultRegion.longitude, defaultRegion.latitude]}
/>
<ViewWrapper>
<MapsV2Custom markers={markers} />
</ViewWrapper>
{list?.map((item: TypeMaps) => {
const imageUrl = API_IMAGE.GET({ fileId: item.Portofolio.logoId });
return (
<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>
<DrawerMaps
openDrawer={openDrawer}
setOpenDrawer={setOpenDrawer}
selected={selected}
/>
</>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
map: { flex: 1 },
markerContainer: {
width: 30,
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,
map: {
flex: 1,
width: "50%",
maxHeight: "50%",
},
markerImage: {
width: "100%",
height: "100%",
},
});
});

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;

View File

@@ -1,19 +1,42 @@
import {
BaseBox,
MapCustom,
StackCustom,
TextCustom
} from "@/components";
import { StyleSheet, View } from "react-native";
import { BaseBox, StackCustom, TextCustom } from "@/components";
import { MapsV2Custom } from "@/components/Map/MapsV2Custom";
export default function Portofolio_BusinessLocation({
data,
imageId,
setOpenDrawerLocation,
}: {
data: any;
data: {
id: string;
imageId: string;
latitude: number;
longitude: number;
namePin: string;
pinId: string;
} | null;
imageId?: string;
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 (
<>
<BaseBox style={{ height: !data ? 200 : "auto" }}>
@@ -30,18 +53,33 @@ export default function Portofolio_BusinessLocation({
Lokasi bisnis belum ditambahkan
</TextCustom>
) : (
<MapCustom
latitude={data?.latitude}
longitude={data?.longitude}
namePin={data?.namePin}
imageId={imageId}
onPress={() => {
setOpenDrawerLocation(true);
}}
/>
<View style={styles.mapContainer}>
<MapsV2Custom
markers={markers}
zoomLevel={15}
showDefaultMarkers={true}
markerSize={35}
initialRegion={{
latitude: data?.latitude,
longitude: data?.longitude,
latitudeDelta: 0.1,
longitudeDelta: 0.1,
}}
/>
</View>
)}
</StackCustom>
</BaseBox>
</>
);
}
const styles = StyleSheet.create({
mapContainer: {
width: "100%",
height: 250,
borderRadius: 8,
overflow: "hidden",
marginTop: 8,
},
});