feat(map): preview mode + inline rename + floating actions tweaks
Comportement 2-clicks sur la liste des scans :
- 1er clic : map zoom sur le scan + sheet snap au plus bas + tile preview
unique avec hint 'Appuyez à nouveau pour voir les détails'
- 2e clic même scan : navigate vers ScanDetail
- Clic autre scan en preview : switch preview + zoom
Rename inline dans le BottomSheet existant (plus de 2e sheet superposé) :
- Bouton retour (ChevronLeft) au lieu de croix
- Form (BottomSheetTextInput) avec keyboardBehavior='interactive' +
android_keyboardInputMode='adjustResize' pour ne pas masquer l'input
- Boutons Annuler/Enregistrer avec icônes (X / Check), border 1.5px primary,
shadow primary[900] sur le Save, minHeight 56px
- snapToIndex(2) à l'ouverture du form (85%) puis snapToIndex(0) après
save/cancel pour redonner la map à voir
- containerStyle { zIndex:100, elevation:100 } pour passer au-dessus des
FloatingActions quand le sheet s'expand
FloatingActions : couleur d'icône via prop color (className n'est pas
supporté pour la couleur sur lucide-react-native sans cssInterop).
actionsSlot : zIndex/elevation 1 pour passer derrière le sheet expanded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7457e64996
commit
98c446cd35
|
|
@ -1,4 +1,5 @@
|
||||||
import { View, Pressable, StyleSheet } from "react-native";
|
import React from "react";
|
||||||
|
import { View, Pressable } from "react-native";
|
||||||
import { Layers, LocateFixed, Satellite } from "lucide-react-native";
|
import { Layers, LocateFixed, Satellite } from "lucide-react-native";
|
||||||
|
|
||||||
import { colors } from "@/theme/colors";
|
import { colors } from "@/theme/colors";
|
||||||
|
|
@ -12,6 +13,10 @@ interface FloatingActionsProps {
|
||||||
activeAction?: FloatingActionId;
|
activeAction?: FloatingActionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getIconColor(isActive: boolean) {
|
||||||
|
return isActive ? "#FFFFFF" : colors.primary[800];
|
||||||
|
}
|
||||||
|
|
||||||
export function FloatingActions({
|
export function FloatingActions({
|
||||||
onLayers,
|
onLayers,
|
||||||
onLocate,
|
onLocate,
|
||||||
|
|
@ -19,25 +24,27 @@ export function FloatingActions({
|
||||||
activeAction,
|
activeAction,
|
||||||
}: FloatingActionsProps) {
|
}: FloatingActionsProps) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.column} collapsable={false}>
|
<View className="gap-3 shadow-2xl" collapsable={false}>
|
||||||
<ActionButton active={activeAction === "layers"} onPress={onLayers}>
|
<ActionButton active={activeAction === "layers"} onPress={onLayers}>
|
||||||
<Layers
|
<Layers
|
||||||
size={22}
|
size={20}
|
||||||
color={activeAction === "layers" ? "#FFFFFF" : colors.primary[800]}
|
color={getIconColor(activeAction === "layers")}
|
||||||
strokeWidth={2.2}
|
strokeWidth={2.2}
|
||||||
/>
|
/>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
||||||
<ActionButton active={activeAction === "locate"} onPress={onLocate}>
|
<ActionButton active={activeAction === "locate"} onPress={onLocate}>
|
||||||
<LocateFixed
|
<LocateFixed
|
||||||
size={22}
|
size={20}
|
||||||
color={activeAction === "locate" ? "#FFFFFF" : colors.primary[800]}
|
color={getIconColor(activeAction === "locate")}
|
||||||
strokeWidth={2.2}
|
strokeWidth={2.2}
|
||||||
/>
|
/>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
||||||
<ActionButton active={activeAction === "satellite"} onPress={onSatellite}>
|
<ActionButton active={activeAction === "satellite"} onPress={onSatellite}>
|
||||||
<Satellite
|
<Satellite
|
||||||
size={22}
|
size={20}
|
||||||
color={activeAction === "satellite" ? "#FFFFFF" : colors.primary[800]}
|
color={getIconColor(activeAction === "satellite")}
|
||||||
strokeWidth={2.2}
|
strokeWidth={2.2}
|
||||||
/>
|
/>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
@ -55,45 +62,14 @@ function ActionButton({ children, onPress, active }: ActionButtonProps) {
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
|
className={`w-10 h-10 rounded-full items-center justify-center shadow-lg ${
|
||||||
|
active ? "bg-primary" : "bg-card border border-border"
|
||||||
|
}`}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.button,
|
{ transform: [{ scale: pressed ? 0.95 : 1 }] },
|
||||||
active ? styles.buttonActive : styles.buttonInactive,
|
|
||||||
pressed && { transform: [{ scale: 0.95 }] },
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
column: {
|
|
||||||
gap: 12,
|
|
||||||
elevation: 24,
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 999,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
buttonInactive: {
|
|
||||||
backgroundColor: "#FFFFFF",
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#E5E7EB",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
shadowOpacity: 0.18,
|
|
||||||
shadowRadius: 12,
|
|
||||||
elevation: 24,
|
|
||||||
},
|
|
||||||
buttonActive: {
|
|
||||||
backgroundColor: colors.primary[900],
|
|
||||||
shadowColor: colors.primary[900],
|
|
||||||
shadowOffset: { width: 0, height: 6 },
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 12,
|
|
||||||
elevation: 24,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,25 @@
|
||||||
import { forwardRef, useMemo, useState } from "react";
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Modal,
|
Text as RNText,
|
||||||
TextInput,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import BottomSheet, { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import BottomSheet, {
|
||||||
|
BottomSheetScrollView,
|
||||||
|
BottomSheetTextInput,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronLeft,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Leaf,
|
Leaf,
|
||||||
Clock,
|
Clock,
|
||||||
|
|
@ -19,6 +27,7 @@ import {
|
||||||
X,
|
X,
|
||||||
ScanLine,
|
ScanLine,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
Check,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
@ -29,6 +38,8 @@ import type { ScanRecord, ScanStatus } from "@/types/detection";
|
||||||
|
|
||||||
interface MapBottomSheetProps {
|
interface MapBottomSheetProps {
|
||||||
scans: ScanRecord[];
|
scans: ScanRecord[];
|
||||||
|
previewScan?: ScanRecord | null;
|
||||||
|
onPreviewClose?: () => void;
|
||||||
onScanPress?: (scan: ScanRecord) => void;
|
onScanPress?: (scan: ScanRecord) => void;
|
||||||
onRename?: (scanId: string, newName: string) => void;
|
onRename?: (scanId: string, newName: string) => void;
|
||||||
onScanCta?: () => void;
|
onScanCta?: () => void;
|
||||||
|
|
@ -36,15 +47,49 @@ interface MapBottomSheetProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MapBottomSheet = forwardRef<BottomSheet, MapBottomSheetProps>(
|
export const MapBottomSheet = forwardRef<BottomSheet, MapBottomSheetProps>(
|
||||||
function MapBottomSheet({ scans, onScanPress, onRename, onScanCta, defaultIndex = 0 }, ref) {
|
function MapBottomSheet(
|
||||||
|
{
|
||||||
|
scans,
|
||||||
|
previewScan,
|
||||||
|
onPreviewClose,
|
||||||
|
onScanPress,
|
||||||
|
onRename,
|
||||||
|
onScanCta,
|
||||||
|
defaultIndex = 0,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const snapPoints = useMemo(() => ["20%", "55%", "85%"], []);
|
const snapPoints = useMemo(() => ["20%", "55%", "85%"], []);
|
||||||
|
const internalRef = useRef<BottomSheet>(null);
|
||||||
const [renamingScan, setRenamingScan] = useState<ScanRecord | null>(null);
|
const [renamingScan, setRenamingScan] = useState<ScanRecord | null>(null);
|
||||||
const [draftName, setDraftName] = useState("");
|
const [draftName, setDraftName] = useState("");
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
snapToIndex: (i: number) => internalRef.current?.snapToIndex(i),
|
||||||
|
snapToPosition: (p: number | string) =>
|
||||||
|
internalRef.current?.snapToPosition(p),
|
||||||
|
expand: () => internalRef.current?.expand(),
|
||||||
|
collapse: () => internalRef.current?.collapse(),
|
||||||
|
close: () => internalRef.current?.close(),
|
||||||
|
forceClose: () => internalRef.current?.forceClose(),
|
||||||
|
}) as BottomSheet,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (renamingScan) {
|
||||||
|
setDraftName(getScanDisplayName(renamingScan, t));
|
||||||
|
}
|
||||||
|
}, [renamingScan, t]);
|
||||||
|
|
||||||
function handleStartRename(scan: ScanRecord) {
|
function handleStartRename(scan: ScanRecord) {
|
||||||
setRenamingScan(scan);
|
setRenamingScan(scan);
|
||||||
setDraftName(getScanDisplayName(scan, t));
|
// remonte à 85% pour bien voir l'input + boutons au-dessus du clavier
|
||||||
|
internalRef.current?.snapToIndex(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleConfirmRename() {
|
function handleConfirmRename() {
|
||||||
|
|
@ -53,31 +98,137 @@ export const MapBottomSheet = forwardRef<BottomSheet, MapBottomSheetProps>(
|
||||||
}
|
}
|
||||||
setRenamingScan(null);
|
setRenamingScan(null);
|
||||||
setDraftName("");
|
setDraftName("");
|
||||||
|
// redescend pour voir la map
|
||||||
|
internalRef.current?.snapToIndex(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancelRename() {
|
function handleCancelRename() {
|
||||||
setRenamingScan(null);
|
setRenamingScan(null);
|
||||||
setDraftName("");
|
setDraftName("");
|
||||||
|
// redescend pour voir la map
|
||||||
|
internalRef.current?.snapToIndex(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BottomSheet
|
<BottomSheet
|
||||||
ref={ref}
|
ref={internalRef}
|
||||||
index={defaultIndex}
|
index={defaultIndex}
|
||||||
snapPoints={snapPoints}
|
snapPoints={snapPoints}
|
||||||
handleIndicatorStyle={styles.handleIndicator}
|
handleIndicatorStyle={styles.handleIndicator}
|
||||||
backgroundStyle={styles.background}
|
backgroundStyle={styles.background}
|
||||||
|
containerStyle={styles.sheetContainer}
|
||||||
|
enableDynamicSizing={false}
|
||||||
enablePanDownToClose={false}
|
enablePanDownToClose={false}
|
||||||
|
keyboardBehavior="interactive"
|
||||||
|
keyboardBlurBehavior="restore"
|
||||||
|
android_keyboardInputMode="adjustResize"
|
||||||
>
|
>
|
||||||
<View style={styles.header}>
|
{renamingScan ? (
|
||||||
<Text style={styles.title}>{t("map.scannedPlants")}</Text>
|
<BottomSheetScrollView
|
||||||
<Text style={styles.count}>
|
contentContainerStyle={[
|
||||||
{t("map.plantCount", { count: scans.length })}
|
styles.renameWrap,
|
||||||
</Text>
|
{ paddingBottom: 24 },
|
||||||
</View>
|
]}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={styles.renameHeader}>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleCancelRename}
|
||||||
|
hitSlop={10}
|
||||||
|
style={styles.backBtn}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={20} color={colors.neutral[700]} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.title}>{t("map.rename.title")}</Text>
|
||||||
|
<View style={styles.backBtnPlaceholder} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.renameSubtitle}>
|
||||||
|
{t("map.rename.subtitle")}
|
||||||
|
</Text>
|
||||||
|
<BottomSheetTextInput
|
||||||
|
value={draftName}
|
||||||
|
onChangeText={setDraftName}
|
||||||
|
placeholder={t("map.rename.placeholder")}
|
||||||
|
placeholderTextColor={colors.neutral[400]}
|
||||||
|
autoFocus
|
||||||
|
maxLength={64}
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={handleConfirmRename}
|
||||||
|
style={styles.renameInput}
|
||||||
|
/>
|
||||||
|
<View style={styles.renameActions}>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleCancelRename}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.renameBtn,
|
||||||
|
styles.renameBtnGhost,
|
||||||
|
pressed && { opacity: 0.7 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.renameBtnInner}>
|
||||||
|
<X
|
||||||
|
size={18}
|
||||||
|
color={colors.neutral[800]}
|
||||||
|
strokeWidth={2.4}
|
||||||
|
/>
|
||||||
|
<RNText style={styles.renameBtnGhostLabel}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</RNText>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleConfirmRename}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.renameBtn,
|
||||||
|
styles.renameBtnPrimary,
|
||||||
|
pressed && { opacity: 0.85 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.renameBtnInner}>
|
||||||
|
<Check size={18} color="#FFFFFF" strokeWidth={2.6} />
|
||||||
|
<RNText style={styles.renameBtnPrimaryLabel}>
|
||||||
|
{t("map.rename.save")}
|
||||||
|
</RNText>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
) : previewScan ? (
|
||||||
|
<View>
|
||||||
|
<View style={styles.previewHeader}>
|
||||||
|
<Text style={styles.title}>{t("map.preview.title")}</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={onPreviewClose}
|
||||||
|
hitSlop={10}
|
||||||
|
style={styles.previewCloseBtn}
|
||||||
|
>
|
||||||
|
<X size={18} color={colors.neutral[600]} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View style={styles.previewBody}>
|
||||||
|
<ScanRow
|
||||||
|
scan={previewScan}
|
||||||
|
isLast
|
||||||
|
onPress={() => onScanPress?.(previewScan)}
|
||||||
|
onEdit={() => handleStartRename(previewScan)}
|
||||||
|
/>
|
||||||
|
<Text style={styles.previewHint}>
|
||||||
|
{t("map.preview.tapHint")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("map.scannedPlants")}</Text>
|
||||||
|
<Text style={styles.count}>
|
||||||
|
{t("map.plantCount", { count: scans.length })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
{scans.length === 0 ? (
|
{scans.length === 0 ? (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<View style={styles.emptyIconWrap}>
|
<View style={styles.emptyIconWrap}>
|
||||||
<MapPin size={32} color={colors.primary[800]} strokeWidth={2} />
|
<MapPin size={32} color={colors.primary[800]} strokeWidth={2} />
|
||||||
|
|
@ -112,68 +263,10 @@ export const MapBottomSheet = forwardRef<BottomSheet, MapBottomSheetProps>(
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</BottomSheetScrollView>
|
</BottomSheetScrollView>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
|
|
||||||
<Modal
|
|
||||||
visible={renamingScan !== null}
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
onRequestClose={handleCancelRename}
|
|
||||||
>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
|
||||||
style={styles.modalOverlay}
|
|
||||||
>
|
|
||||||
<Pressable style={styles.modalBackdrop} onPress={handleCancelRename} />
|
|
||||||
<View style={styles.modalCard}>
|
|
||||||
<View style={styles.modalHeader}>
|
|
||||||
<Text style={styles.modalTitle}>{t("map.rename.title")}</Text>
|
|
||||||
<Pressable onPress={handleCancelRename} hitSlop={10}>
|
|
||||||
<X size={20} color={colors.neutral[600]} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.modalSubtitle}>{t("map.rename.subtitle")}</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.modalInput}
|
|
||||||
value={draftName}
|
|
||||||
onChangeText={setDraftName}
|
|
||||||
placeholder={t("map.rename.placeholder")}
|
|
||||||
placeholderTextColor={colors.neutral[400]}
|
|
||||||
autoFocus
|
|
||||||
maxLength={64}
|
|
||||||
returnKeyType="done"
|
|
||||||
onSubmitEditing={handleConfirmRename}
|
|
||||||
/>
|
|
||||||
<View style={styles.modalActions}>
|
|
||||||
<Pressable
|
|
||||||
onPress={handleCancelRename}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.modalButton,
|
|
||||||
styles.modalButtonGhost,
|
|
||||||
pressed && { opacity: 0.7 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={styles.modalButtonGhostLabel}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={handleConfirmRename}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.modalButton,
|
|
||||||
styles.modalButtonPrimary,
|
|
||||||
pressed && { opacity: 0.85 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={styles.modalButtonPrimaryLabel}>
|
|
||||||
{t("map.rename.save")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -235,6 +328,10 @@ function ScanRow({ scan, isLast, onPress, onEdit }: ScanRowProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
sheetContainer: {
|
||||||
|
zIndex: 100,
|
||||||
|
elevation: 100,
|
||||||
|
},
|
||||||
background: {
|
background: {
|
||||||
backgroundColor: "#FFFFFF",
|
backgroundColor: "#FFFFFF",
|
||||||
borderTopLeftRadius: 28,
|
borderTopLeftRadius: 28,
|
||||||
|
|
@ -258,6 +355,34 @@ const styles = StyleSheet.create({
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
paddingBottom: 12,
|
paddingBottom: 12,
|
||||||
},
|
},
|
||||||
|
previewHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 4,
|
||||||
|
},
|
||||||
|
previewCloseBtn: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 999,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: colors.neutral[100],
|
||||||
|
},
|
||||||
|
previewBody: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 20,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
previewHint: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: colors.neutral[500],
|
||||||
|
textAlign: "center",
|
||||||
|
paddingTop: 4,
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
|
|
@ -359,75 +484,91 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
},
|
},
|
||||||
modalOverlay: {
|
// Rename inline form
|
||||||
flex: 1,
|
renameWrap: {
|
||||||
justifyContent: "center",
|
paddingHorizontal: 20,
|
||||||
alignItems: "center",
|
paddingTop: 4,
|
||||||
paddingHorizontal: 24,
|
paddingBottom: 24,
|
||||||
},
|
|
||||||
modalBackdrop: {
|
|
||||||
...StyleSheet.absoluteFillObject,
|
|
||||||
backgroundColor: "rgba(0,0,0,0.45)",
|
|
||||||
},
|
|
||||||
modalCard: {
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 400,
|
|
||||||
backgroundColor: "#FFFFFF",
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: 20,
|
|
||||||
gap: 12,
|
gap: 12,
|
||||||
},
|
},
|
||||||
modalHeader: {
|
renameHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
},
|
},
|
||||||
modalTitle: {
|
backBtn: {
|
||||||
fontSize: 18,
|
width: 36,
|
||||||
fontWeight: "700",
|
height: 36,
|
||||||
color: colors.neutral[900],
|
borderRadius: 999,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: colors.neutral[100],
|
||||||
},
|
},
|
||||||
modalSubtitle: {
|
backBtnPlaceholder: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
},
|
||||||
|
renameSubtitle: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: colors.neutral[600],
|
color: colors.neutral[600],
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
|
paddingHorizontal: 4,
|
||||||
},
|
},
|
||||||
modalInput: {
|
renameInput: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.neutral[300],
|
borderColor: colors.neutral[300],
|
||||||
borderRadius: 12,
|
borderRadius: 14,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 12,
|
paddingVertical: 14,
|
||||||
fontSize: 15,
|
fontSize: 16,
|
||||||
color: colors.neutral[900],
|
color: colors.neutral[900],
|
||||||
backgroundColor: "#FAFAFA",
|
backgroundColor: "#FAFAFA",
|
||||||
},
|
},
|
||||||
modalActions: {
|
renameActions: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: 10,
|
paddingTop: 16,
|
||||||
marginTop: 4,
|
gap: 12,
|
||||||
},
|
},
|
||||||
modalButton: {
|
renameBtn: {
|
||||||
flex: 1,
|
flexGrow: 1,
|
||||||
paddingVertical: 12,
|
flexShrink: 1,
|
||||||
borderRadius: 12,
|
flexBasis: 0,
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 14,
|
||||||
|
minHeight: 56,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
modalButtonGhost: {
|
renameBtnInner: {
|
||||||
backgroundColor: colors.neutral[100],
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
modalButtonGhostLabel: {
|
renameBtnGhost: {
|
||||||
fontSize: 15,
|
backgroundColor: colors.neutral[200],
|
||||||
fontWeight: "600",
|
borderWidth: 1.5,
|
||||||
|
borderColor: colors.neutral[400],
|
||||||
|
},
|
||||||
|
renameBtnGhostLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
color: colors.neutral[800],
|
color: colors.neutral[800],
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
marginLeft: 8,
|
||||||
},
|
},
|
||||||
modalButtonPrimary: {
|
renameBtnPrimary: {
|
||||||
backgroundColor: colors.primary[800],
|
backgroundColor: colors.primary[800],
|
||||||
|
shadowColor: colors.primary[900],
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
},
|
},
|
||||||
modalButtonPrimaryLabel: {
|
renameBtnPrimaryLabel: {
|
||||||
fontSize: 15,
|
fontSize: 16,
|
||||||
fontWeight: "600",
|
fontWeight: "700",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
marginLeft: 8,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,11 @@ import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { VineyardMapView, type VineyardMapHandle, type MapRegion } from "@/components/map/MapView";
|
import {
|
||||||
|
VineyardMapView,
|
||||||
|
type VineyardMapHandle,
|
||||||
|
type MapRegion,
|
||||||
|
} from "@/components/map/MapView";
|
||||||
import { FloatingSearch } from "@/components/map/FloatingSearch";
|
import { FloatingSearch } from "@/components/map/FloatingSearch";
|
||||||
import { FloatingActions } from "@/components/map/FloatingActions";
|
import { FloatingActions } from "@/components/map/FloatingActions";
|
||||||
import { MapBottomSheet } from "@/components/map/MapBottomSheet";
|
import { MapBottomSheet } from "@/components/map/MapBottomSheet";
|
||||||
|
|
@ -27,8 +31,12 @@ const DEFAULT_REGION: MapRegion = {
|
||||||
longitudeDelta: 0.12,
|
longitudeDelta: 0.12,
|
||||||
};
|
};
|
||||||
|
|
||||||
function hasLocation(scan: ScanRecord): scan is ScanRecord & { latitude: number; longitude: number } {
|
function hasLocation(
|
||||||
return typeof scan.latitude === "number" && typeof scan.longitude === "number";
|
scan: ScanRecord,
|
||||||
|
): scan is ScanRecord & { latitude: number; longitude: number } {
|
||||||
|
return (
|
||||||
|
typeof scan.latitude === "number" && typeof scan.longitude === "number"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeInitialRegion(scans: ScanRecord[]): MapRegion {
|
function computeInitialRegion(scans: ScanRecord[]): MapRegion {
|
||||||
|
|
@ -67,18 +75,27 @@ export default function MapScreen() {
|
||||||
const geojsonCache = useRef<Map<string, object>>(new Map());
|
const geojsonCache = useRef<Map<string, object>>(new Map());
|
||||||
const activeFilterRef = useRef<string | null>(null);
|
const activeFilterRef = useRef<string | null>(null);
|
||||||
const [activeFilter, setActiveFilter] = useState<string | null>(null);
|
const [activeFilter, setActiveFilter] = useState<string | null>(null);
|
||||||
|
const [previewScan, setPreviewScan] = useState<ScanRecord | null>(null);
|
||||||
activeFilterRef.current = activeFilter;
|
activeFilterRef.current = activeFilter;
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
reload();
|
reload();
|
||||||
}, [reload])
|
}, [reload]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const locatedScans = useMemo(() => history.filter(hasLocation), [history]);
|
const locatedScans = useMemo(() => history.filter(hasLocation), [history]);
|
||||||
const initialRegion = useMemo(() => computeInitialRegion(history), [history]);
|
const initialRegion = useMemo(() => computeInitialRegion(history), [history]);
|
||||||
|
|
||||||
function handleScanPress(scan: ScanRecord) {
|
function handleScanPress(scan: ScanRecord) {
|
||||||
|
// 2nd click on the same scan in preview → open detail
|
||||||
|
if (previewScan?.id === scan.id) {
|
||||||
|
setPreviewScan(null);
|
||||||
|
navigation.navigate("ScanDetail", { scanId: scan.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1st click (or click on a different scan) → preview mode
|
||||||
if (hasLocation(scan)) {
|
if (hasLocation(scan)) {
|
||||||
mapRef.current?.animateToRegion({
|
mapRef.current?.animateToRegion({
|
||||||
latitude: scan.latitude,
|
latitude: scan.latitude,
|
||||||
|
|
@ -89,7 +106,12 @@ export default function MapScreen() {
|
||||||
}
|
}
|
||||||
setActiveFilter(null);
|
setActiveFilter(null);
|
||||||
mapRef.current?.highlightGeoJSON(null);
|
mapRef.current?.highlightGeoJSON(null);
|
||||||
navigation.navigate("ScanDetail", { scanId: scan.id });
|
setPreviewScan(scan);
|
||||||
|
sheetRef.current?.snapToIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreviewClose() {
|
||||||
|
setPreviewScan(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLocateUser() {
|
async function handleLocateUser() {
|
||||||
|
|
@ -183,18 +205,12 @@ export default function MapScreen() {
|
||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
>
|
>
|
||||||
<FloatingSearch activeFilter={activeFilter} onFilterPress={handleFilterPress} />
|
<FloatingSearch
|
||||||
|
activeFilter={activeFilter}
|
||||||
|
onFilterPress={handleFilterPress}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<MapBottomSheet
|
|
||||||
ref={sheetRef}
|
|
||||||
scans={locatedScans}
|
|
||||||
onScanPress={handleScanPress}
|
|
||||||
onRename={handleRename}
|
|
||||||
onScanCta={() => navigation.navigate("Main", { screen: "Scanner" })}
|
|
||||||
defaultIndex={isEmpty ? 1 : 0}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={styles.actionsSlot}
|
style={styles.actionsSlot}
|
||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
|
|
@ -207,6 +223,17 @@ export default function MapScreen() {
|
||||||
activeAction={activeFilter === "myLocation" ? "locate" : "layers"}
|
activeAction={activeFilter === "myLocation" ? "locate" : "layers"}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<MapBottomSheet
|
||||||
|
ref={sheetRef}
|
||||||
|
scans={locatedScans}
|
||||||
|
previewScan={previewScan}
|
||||||
|
onPreviewClose={handlePreviewClose}
|
||||||
|
onScanPress={handleScanPress}
|
||||||
|
onRename={handleRename}
|
||||||
|
onScanCta={() => navigation.navigate("Main", { screen: "Scanner" })}
|
||||||
|
defaultIndex={isEmpty ? 1 : 0}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -228,14 +255,12 @@ const styles = StyleSheet.create({
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
zIndex: 20,
|
|
||||||
elevation: 24,
|
|
||||||
},
|
},
|
||||||
actionsSlot: {
|
actionsSlot: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: 16,
|
right: 16,
|
||||||
top: "30%",
|
top: "30%",
|
||||||
zIndex: 20,
|
zIndex: 1,
|
||||||
elevation: 24,
|
elevation: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue