@robono/location-react-native 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +130 -0
- package/README.md +113 -2
- package/SBOM.spdx.json +719 -0
- package/app.plugin.cjs +59 -0
- package/dist/AvatarLocationMap.d.ts +14 -0
- package/dist/AvatarLocationMap.js +203 -0
- package/dist/GeofenceEditor.d.ts +23 -0
- package/dist/GeofenceEditor.js +112 -0
- package/dist/LocationScreen.d.ts +30 -0
- package/dist/LocationScreen.js +142 -0
- package/dist/MapView.d.ts +15 -0
- package/dist/MapView.js +16 -0
- package/dist/expo.d.ts +65 -0
- package/dist/expo.js +425 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/motion.d.ts +16 -0
- package/dist/motion.js +45 -0
- package/dist/visual/RobonoIcon.d.ts +232 -0
- package/dist/visual/RobonoIcon.js +20 -0
- package/dist/visual/icon-nodes.d.ts +3 -0
- package/dist/visual/icon-nodes.js +2 -0
- package/dist/visual/icon-paths.d.ts +221 -0
- package/dist/visual/icon-paths.js +221 -0
- package/licenses/robono-icons-lucide-license.txt +43 -0
- package/licenses/robono-icons-weather-attribution.txt +1 -0
- package/licenses/robono-icons-weather-license.txt +21 -0
- package/package.json +72 -4
package/app.plugin.cjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { createRunOncePlugin, withPlugins } = require("expo/config-plugins");
|
|
2
|
+
function withRobonoLocation(config, options = {}) {
|
|
3
|
+
const fs = require("node:fs"), path = require("node:path");
|
|
4
|
+
const read = (platform) => {
|
|
5
|
+
const file = options.accessFiles?.[platform];
|
|
6
|
+
if (!file) return undefined;
|
|
7
|
+
const value = JSON.parse(
|
|
8
|
+
fs.readFileSync(
|
|
9
|
+
path.resolve(config._internal?.projectRoot ?? process.cwd(), file),
|
|
10
|
+
"utf8",
|
|
11
|
+
),
|
|
12
|
+
);
|
|
13
|
+
if (
|
|
14
|
+
value.platform !== platform || typeof value.mapAccess !== "string" ||
|
|
15
|
+
value.identifier !==
|
|
16
|
+
(platform === "ios"
|
|
17
|
+
? config.ios?.bundleIdentifier
|
|
18
|
+
: config.android?.package)
|
|
19
|
+
) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
"Robono Maps configuration does not match this app identifier.",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return value.mapAccess;
|
|
25
|
+
};
|
|
26
|
+
options = {
|
|
27
|
+
...options,
|
|
28
|
+
iosMapKey: read("ios") ?? options.iosMapKey,
|
|
29
|
+
androidMapKey: read("android") ?? options.androidMapKey,
|
|
30
|
+
};
|
|
31
|
+
const {
|
|
32
|
+
iosMapKey,
|
|
33
|
+
androidMapKey,
|
|
34
|
+
background = false,
|
|
35
|
+
permissionText =
|
|
36
|
+
"Allow this app to share your location with the people you choose.",
|
|
37
|
+
} = options;
|
|
38
|
+
if (!iosMapKey || !androidMapKey) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"Robono Maps access must be provisioned for both app identifiers.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if(options.motion) config=withPlugins(config, [["expo-sensors", {motionPermission:options.motionPermission ?? "Allow this app to share device movement with the people you choose. This does not detect screen use."}]]);
|
|
44
|
+
return withPlugins(config, [["expo-location", {
|
|
45
|
+
locationWhenInUsePermission: permissionText,
|
|
46
|
+
locationAlwaysAndWhenInUsePermission: permissionText,
|
|
47
|
+
isIosBackgroundLocationEnabled: background,
|
|
48
|
+
isAndroidBackgroundLocationEnabled: background,
|
|
49
|
+
isAndroidForegroundServiceEnabled: background,
|
|
50
|
+
}], ["react-native-maps", {
|
|
51
|
+
iosGoogleMapsApiKey: iosMapKey,
|
|
52
|
+
androidGoogleMapsApiKey: androidMapKey,
|
|
53
|
+
}]]);
|
|
54
|
+
}
|
|
55
|
+
module.exports = createRunOncePlugin(
|
|
56
|
+
withRobonoLocation,
|
|
57
|
+
"@robono/location-react-native",
|
|
58
|
+
"0.1.0",
|
|
59
|
+
);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Coordinate } from "@robono/location";
|
|
2
|
+
/** The avatar stays on the marker when the anchored details are dismissed. */
|
|
3
|
+
export declare function AvatarLocationMap({ position, name, avatar, updated, accuracyMeters, route, destination, onDirections, onMapReady, onLocateMe, }: {
|
|
4
|
+
position: Coordinate;
|
|
5
|
+
name: string;
|
|
6
|
+
avatar?: string | null;
|
|
7
|
+
updated: string;
|
|
8
|
+
accuracyMeters?: number;
|
|
9
|
+
route?: Coordinate[];
|
|
10
|
+
destination?: Coordinate | null;
|
|
11
|
+
onDirections: () => void;
|
|
12
|
+
onMapReady?: () => void;
|
|
13
|
+
onLocateMe?: (signal: AbortSignal) => Promise<Coordinate>;
|
|
14
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState, } from "react";
|
|
3
|
+
import { ActivityIndicator, Image, Pressable, StyleSheet, Text, View } from "react-native";
|
|
4
|
+
import MapView, { Circle, Marker, Polyline, PROVIDER_GOOGLE } from "react-native-maps";
|
|
5
|
+
import Ionicons from "./visual/RobonoIcon";
|
|
6
|
+
/** The avatar stays on the marker when the anchored details are dismissed. */
|
|
7
|
+
export function AvatarLocationMap({ position, name, avatar, updated, accuracyMeters, route = [], destination, onDirections, onMapReady, onLocateMe, }) {
|
|
8
|
+
const map = useRef(null), marker = useRef(null), generation = useRef(0), viewerRequest = useRef(null);
|
|
9
|
+
const [open, setOpen] = useState(true), [anchor, setAnchor] = useState(null), [size, setSize] = useState({ width: 0, height: 0 }), [ready, setReady] = useState(false), [popupHeight, setPopupHeight] = useState(84), [imageFailed, setImageFailed] = useState(false), [mapType, setMapType] = useState("standard"), [viewerPosition, setViewerPosition] = useState(null), [locating, setLocating] = useState(false), [locateError, setLocateError] = useState("");
|
|
10
|
+
const latest = useRef(position);
|
|
11
|
+
latest.current = position;
|
|
12
|
+
const project = useCallback(async () => {
|
|
13
|
+
const version = ++generation.current;
|
|
14
|
+
try {
|
|
15
|
+
const point = await map.current?.pointForCoordinate(latest.current);
|
|
16
|
+
if (version === generation.current && point)
|
|
17
|
+
setAnchor(point);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
if (version === generation.current)
|
|
21
|
+
setAnchor(null);
|
|
22
|
+
}
|
|
23
|
+
}, []);
|
|
24
|
+
const redraw = useCallback(() => {
|
|
25
|
+
requestAnimationFrame(() => requestAnimationFrame(() => marker.current?.redraw()));
|
|
26
|
+
}, []);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
setImageFailed(false);
|
|
29
|
+
redraw();
|
|
30
|
+
}, [avatar, redraw]);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (ready) {
|
|
33
|
+
void project();
|
|
34
|
+
redraw();
|
|
35
|
+
}
|
|
36
|
+
}, [position.latitude, position.longitude, ready, project, redraw]);
|
|
37
|
+
useEffect(() => () => {
|
|
38
|
+
generation.current++;
|
|
39
|
+
viewerRequest.current?.abort();
|
|
40
|
+
}, []);
|
|
41
|
+
const locateFriend = () => {
|
|
42
|
+
// An explicit friend tap wins over an in-flight request to frame both people.
|
|
43
|
+
viewerRequest.current?.abort();
|
|
44
|
+
viewerRequest.current = null;
|
|
45
|
+
setLocating(false);
|
|
46
|
+
setLocateError("");
|
|
47
|
+
setOpen(true);
|
|
48
|
+
map.current?.animateCamera({ center: latest.current, zoom: 16 }, { duration: 350 });
|
|
49
|
+
};
|
|
50
|
+
const locateBoth = async () => {
|
|
51
|
+
if (!onLocateMe || viewerRequest.current)
|
|
52
|
+
return;
|
|
53
|
+
const request = new AbortController();
|
|
54
|
+
viewerRequest.current = request;
|
|
55
|
+
setLocating(true);
|
|
56
|
+
setLocateError("");
|
|
57
|
+
try {
|
|
58
|
+
const me = await onLocateMe(request.signal);
|
|
59
|
+
if (request.signal.aborted)
|
|
60
|
+
return;
|
|
61
|
+
if (!Number.isFinite(me.latitude) || !Number.isFinite(me.longitude) || Math.abs(me.latitude) > 90 || Math.abs(me.longitude) > 180) {
|
|
62
|
+
throw Error("Your location is unavailable. Try again.");
|
|
63
|
+
}
|
|
64
|
+
setViewerPosition(me);
|
|
65
|
+
map.current?.fitToCoordinates([latest.current, me], {
|
|
66
|
+
edgePadding: { top: popupHeight + 110, right: 76, bottom: 120, left: 36 },
|
|
67
|
+
animated: true,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (!request.signal.aborted)
|
|
72
|
+
setLocateError(error instanceof Error ? error.message : "Could not find your location. Try again.");
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
if (viewerRequest.current === request) {
|
|
76
|
+
viewerRequest.current = null;
|
|
77
|
+
setLocating(false);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const width = Math.min(200, size.width - 24), left = anchor
|
|
82
|
+
? Math.max(12, Math.min(size.width - width - 12, anchor.x - width / 2))
|
|
83
|
+
: 12;
|
|
84
|
+
const above = !!anchor && anchor.y > popupHeight + 84;
|
|
85
|
+
const top = anchor
|
|
86
|
+
? Math.max(8, Math.min(size.height - popupHeight - 12, above ? anchor.y - 65 - popupHeight - 9 : anchor.y + 10))
|
|
87
|
+
: 8;
|
|
88
|
+
const face = (large = false, compact = false) => avatar && !imageFailed
|
|
89
|
+
? (_jsx(Image, { source: { uri: avatar }, style: compact ? s.controlFace : large ? s.face : s.pinFace, onLoad: redraw, onError: () => {
|
|
90
|
+
setImageFailed(true);
|
|
91
|
+
redraw();
|
|
92
|
+
} }))
|
|
93
|
+
: (_jsx(View, { style: [compact ? s.controlFace : large ? s.face : s.pinFace, s.initial], children: _jsx(Text, { style: [s.letter, large && s.popupLetter, compact && s.controlLetter], children: name.trim().charAt(0).toUpperCase() }) }));
|
|
94
|
+
return (_jsxs(View, { style: { flex: 1 }, onLayout: (e) => {
|
|
95
|
+
setSize(e.nativeEvent.layout);
|
|
96
|
+
void project();
|
|
97
|
+
}, children: [_jsxs(MapView, { ref: map, style: StyleSheet.absoluteFill, provider: PROVIDER_GOOGLE, mapType: mapType, maxZoomLevel: 18, toolbarEnabled: false, initialRegion: {
|
|
98
|
+
...position,
|
|
99
|
+
latitudeDelta: .012,
|
|
100
|
+
longitudeDelta: .012,
|
|
101
|
+
}, showsUserLocation: false, showsMyLocationButton: false, onMapReady: () => {
|
|
102
|
+
setReady(true);
|
|
103
|
+
if (route.length) {
|
|
104
|
+
map.current?.fitToCoordinates(route, {
|
|
105
|
+
edgePadding: { top: 220, right: 55, bottom: 70, left: 55 },
|
|
106
|
+
animated: false,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
else if (destination) {
|
|
110
|
+
map.current?.fitToCoordinates([position, destination], {
|
|
111
|
+
edgePadding: { top: 130, right: 55, bottom: 70, left: 55 },
|
|
112
|
+
animated: false,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
void project();
|
|
116
|
+
onMapReady?.();
|
|
117
|
+
}, onRegionChange: () => void project(), onRegionChangeComplete: () => void project(), onPress: (e) => {
|
|
118
|
+
if (e.nativeEvent.action !== "marker-press")
|
|
119
|
+
setOpen(false);
|
|
120
|
+
}, onPoiClick: () => setOpen(false), children: [viewerPosition && (_jsx(Marker, { coordinate: viewerPosition, title: "You", anchor: { x: .5, y: .5 }, children: _jsx(View, { style: s.viewerDot }) })), !!route.length && (_jsx(Polyline, { coordinates: route, strokeColor: "#b75319", strokeWidth: 5 })), !!destination && (_jsx(Marker, { coordinate: destination, title: "Destination", pinColor: "#b75319" })), _jsx(Marker, { ref: marker, coordinate: position, anchor: { x: .5, y: 1 }, tracksViewChanges: false, onPress: () => {
|
|
121
|
+
setOpen(true);
|
|
122
|
+
void project();
|
|
123
|
+
}, accessibilityLabel: `${name}, location`, children: _jsxs(View, { style: s.pin, onLayout: redraw, children: [_jsx(View, { style: s.head, children: face() }), _jsx(View, { style: s.tip })] }) }), Number.isFinite(accuracyMeters) && accuracyMeters > 0 && _jsx(Circle, { center: position, radius: accuracyMeters, strokeColor: "rgba(50,100,200,.4)", fillColor: "rgba(50,100,200,.1)", strokeWidth: 1 })] }), open && anchor && size.width > 0 && anchor.x >= 0 && anchor.x <= size.width && anchor.y >= 0 && anchor.y <= size.height && (_jsxs(View, { style: [s.popup, { width, left, top }], onLayout: event => setPopupHeight(event.nativeEvent.layout.height), accessibilityViewIsModal: false, children: [_jsxs(View, { style: s.person, children: [face(true), _jsxs(View, { style: { flex: 1 }, children: [_jsx(Text, { numberOfLines: 2, style: s.name, children: name }), _jsx(Text, { style: s.updated, children: updated }), Number.isFinite(accuracyMeters) && _jsxs(Text, { style: s.updated, children: ["Accuracy \u00B1", Math.ceil(accuracyMeters), " m"] })] })] }), _jsx(Pressable, { style: s.directions, hitSlop: 4, accessibilityRole: "button", onPress: onDirections, children: _jsx(Text, { style: s.action, children: "Directions" }) }), _jsx(View, { pointerEvents: "none", style: [s.pointer, {
|
|
124
|
+
left: Math.max(18, Math.min(width - 30, anchor.x - left - 9)),
|
|
125
|
+
...(above
|
|
126
|
+
? { bottom: -9, borderTopWidth: 10, borderTopColor: "white" }
|
|
127
|
+
: {
|
|
128
|
+
top: -9,
|
|
129
|
+
borderBottomWidth: 10,
|
|
130
|
+
borderBottomColor: "white",
|
|
131
|
+
}),
|
|
132
|
+
}] })] })), _jsx(View, { style: s.mapTypes, accessibilityRole: "radiogroup", children: [['standard', 'Regular'], ['satellite', 'Satellite']].map(([value, label]) => (_jsx(Pressable, { hitSlop: 6, accessibilityRole: "radio", accessibilityLabel: label + " map", accessibilityState: { checked: mapType === value }, onPress: () => setMapType(value), style: [s.mapType, mapType === value && s.mapTypeSelected], children: _jsx(Text, { style: [s.mapTypeText, mapType === value && s.mapTypeTextSelected], children: label }) }, value))) }), !!locateError && _jsx(Text, { accessibilityRole: "alert", style: s.locateError, children: locateError }), _jsxs(View, { style: s.locateControls, children: [_jsx(Pressable, { style: s.locateButton, hitSlop: 6, accessibilityRole: "button", accessibilityLabel: `Locate ${name}`, disabled: !ready, onPress: locateFriend, children: face(false, true) }), !!onLocateMe && _jsx(Pressable, { style: s.locateButton, hitSlop: 6, accessibilityRole: "button", accessibilityLabel: "Locate me and show both locations", accessibilityState: { busy: locating }, disabled: !ready || locating, onPress: () => void locateBoth(), children: locating ? _jsx(ActivityIndicator, { size: "small", color: "#a34817" }) : _jsx(Ionicons, { name: "locate-outline", size: 20, color: "#333" }) })] })] }));
|
|
133
|
+
}
|
|
134
|
+
const s = StyleSheet.create({
|
|
135
|
+
mapTypes: { position: "absolute", right: 10, top: 10, flexDirection: "row", backgroundColor: "white", borderRadius: 6, padding: 2, elevation: 2, shadowColor: "#000", shadowOpacity: .15, shadowRadius: 4 },
|
|
136
|
+
mapType: { minHeight: 24, paddingHorizontal: 7, justifyContent: "center", borderRadius: 4 },
|
|
137
|
+
mapTypeSelected: { backgroundColor: "#f4e8dd" },
|
|
138
|
+
mapTypeText: { fontSize: 12, color: "#333", fontWeight: "500" },
|
|
139
|
+
mapTypeTextSelected: { color: "#8e3f13", fontWeight: "600" },
|
|
140
|
+
locateControls: { position: "absolute", right: 12, bottom: 30, gap: 12 },
|
|
141
|
+
locateButton: { width: 32, height: 32, borderRadius: 6, backgroundColor: "white", alignItems: "center", justifyContent: "center", elevation: 4, shadowColor: "#000", shadowOpacity: .15, shadowRadius: 4 },
|
|
142
|
+
controlFace: { width: 22, height: 22, borderRadius: 11 },
|
|
143
|
+
controlLetter: { fontSize: 15 },
|
|
144
|
+
viewerDot: { width: 18, height: 18, borderRadius: 9, borderWidth: 3, borderColor: "white", backgroundColor: "#2878df" },
|
|
145
|
+
locateError: { position: "absolute", left: 12, right: 72, bottom: 30, backgroundColor: "white", color: "#333", padding: 10, borderRadius: 10, fontSize: 14 },
|
|
146
|
+
pin: { width: 52, height: 65, alignItems: "center" },
|
|
147
|
+
head: {
|
|
148
|
+
width: 50,
|
|
149
|
+
height: 50,
|
|
150
|
+
borderRadius: 25,
|
|
151
|
+
backgroundColor: "white",
|
|
152
|
+
padding: 3,
|
|
153
|
+
borderWidth: 2,
|
|
154
|
+
borderColor: "#b75319",
|
|
155
|
+
},
|
|
156
|
+
pinFace: { width: 40, height: 40, borderRadius: 20 },
|
|
157
|
+
tip: {
|
|
158
|
+
borderLeftWidth: 9,
|
|
159
|
+
borderRightWidth: 9,
|
|
160
|
+
borderTopWidth: 13,
|
|
161
|
+
borderLeftColor: "transparent",
|
|
162
|
+
borderRightColor: "transparent",
|
|
163
|
+
borderTopColor: "#b75319",
|
|
164
|
+
marginTop: -2,
|
|
165
|
+
},
|
|
166
|
+
face: { width: 28, height: 28, borderRadius: 14 },
|
|
167
|
+
popupLetter: { fontSize: 18 },
|
|
168
|
+
initial: {
|
|
169
|
+
backgroundColor: "#272727",
|
|
170
|
+
alignItems: "center",
|
|
171
|
+
justifyContent: "center",
|
|
172
|
+
},
|
|
173
|
+
letter: { color: "#e8843a", fontWeight: "600", fontSize: 24 },
|
|
174
|
+
popup: {
|
|
175
|
+
position: "absolute",
|
|
176
|
+
backgroundColor: "white",
|
|
177
|
+
borderRadius: 10,
|
|
178
|
+
padding: 8,
|
|
179
|
+
shadowColor: "#000",
|
|
180
|
+
shadowOpacity: .2,
|
|
181
|
+
shadowRadius: 8,
|
|
182
|
+
elevation: 6,
|
|
183
|
+
},
|
|
184
|
+
person: { flexDirection: "row", gap: 6, alignItems: "center" },
|
|
185
|
+
name: { fontSize: 17, fontWeight: "600", color: "#222" },
|
|
186
|
+
updated: { fontSize: 12, color: "#666", marginTop: 1 },
|
|
187
|
+
directions: {
|
|
188
|
+
minHeight: 32,
|
|
189
|
+
justifyContent: "center",
|
|
190
|
+
marginTop: 2,
|
|
191
|
+
alignItems: "center",
|
|
192
|
+
borderTopWidth: 1,
|
|
193
|
+
borderTopColor: "#ddd",
|
|
194
|
+
},
|
|
195
|
+
action: { fontSize: 16, fontWeight: "600", color: "#a34817" },
|
|
196
|
+
pointer: {
|
|
197
|
+
position: "absolute",
|
|
198
|
+
borderLeftWidth: 9,
|
|
199
|
+
borderRightWidth: 9,
|
|
200
|
+
borderLeftColor: "transparent",
|
|
201
|
+
borderRightColor: "transparent",
|
|
202
|
+
},
|
|
203
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type FenceShape, type AreaRules } from "@robono/location";
|
|
3
|
+
export interface GeofenceDraft {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
shape: FenceShape;
|
|
7
|
+
onEnter: boolean;
|
|
8
|
+
onExit: boolean;
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
revision: number;
|
|
11
|
+
rules?: AreaRules;
|
|
12
|
+
}
|
|
13
|
+
/** The map uses onPress to update the controlled shape. Save runs through the authenticated client. */
|
|
14
|
+
export declare function GeofenceEditor({ value, onChange, onSave, onCancel, map, accent, units, scheduled, }: {
|
|
15
|
+
value: GeofenceDraft;
|
|
16
|
+
onChange: (value: GeofenceDraft) => void;
|
|
17
|
+
onSave: (value: GeofenceDraft) => Promise<void>;
|
|
18
|
+
onCancel: () => void;
|
|
19
|
+
map: ReactNode;
|
|
20
|
+
accent?: string;
|
|
21
|
+
units?: "metric" | "imperial";
|
|
22
|
+
scheduled?: boolean;
|
|
23
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Pressable, ScrollView, StyleSheet, Switch, Text, TextInput, View, } from "react-native";
|
|
4
|
+
import { validateFence, defaultAreaRules, validateAreaRules } from "@robono/location";
|
|
5
|
+
/** The map uses onPress to update the controlled shape. Save runs through the authenticated client. */
|
|
6
|
+
export function GeofenceEditor({ value, onChange, onSave, onCancel, map, accent = "#b75319", units = "metric", scheduled = true, }) {
|
|
7
|
+
const rules = value.rules ?? defaultAreaRules(), schedule = rules.schedule;
|
|
8
|
+
const changeRules = (patch) => onChange({ ...value, rules: { ...rules, ...patch } });
|
|
9
|
+
const changeSchedule = (patch) => { if (schedule)
|
|
10
|
+
changeRules({ schedule: { ...schedule, ...patch } }); };
|
|
11
|
+
const [busy, setBusy] = useState(false), [error, setError] = useState("");
|
|
12
|
+
const button = (label, onPress) => (_jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: onPress, style: styles.button, children: _jsx(Text, { style: { color: accent, fontSize: 15 }, children: label }) }));
|
|
13
|
+
async function save() {
|
|
14
|
+
if (busy)
|
|
15
|
+
return;
|
|
16
|
+
setError("");
|
|
17
|
+
try {
|
|
18
|
+
if (!value.name.trim() || value.name.length > 80) {
|
|
19
|
+
throw Error("Give this area a name.");
|
|
20
|
+
}
|
|
21
|
+
validateFence(value.shape);
|
|
22
|
+
validateAreaRules(rules);
|
|
23
|
+
if (value.enabled && !value.onEnter && !value.onExit && !schedule?.arriveBy && !schedule?.leaveBy) {
|
|
24
|
+
throw Error("Choose an arrival or departure alert.");
|
|
25
|
+
}
|
|
26
|
+
setBusy(true);
|
|
27
|
+
await onSave({ ...value, rules, name: value.name.trim() });
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
setError(e instanceof Error ? e.message : "The area could not be saved.");
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
setBusy(false);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return (_jsxs(View, { style: styles.screen, children: [_jsxs(View, { style: styles.header, children: [button("Cancel", onCancel), _jsx(Text, { style: styles.heading, children: "Area alerts" }), button(busy ? "Saving…" : "Save", () => void save())] }), _jsx(View, { style: styles.map, children: map }), _jsxs(ScrollView, { style: styles.form, contentContainerStyle: { padding: 20 }, keyboardShouldPersistTaps: "handled", children: [_jsx(Text, { style: styles.label, children: "Area name" }), _jsx(TextInput, { accessibilityLabel: "Area name", value: value.name, maxLength: 80, onChangeText: (name) => onChange({ ...value, name }), style: styles.input }), _jsxs(View, { style: styles.row, children: [button("Circle", () => onChange({
|
|
37
|
+
...value,
|
|
38
|
+
shape: {
|
|
39
|
+
kind: "circle",
|
|
40
|
+
center: value.shape.kind === "circle"
|
|
41
|
+
? value.shape.center
|
|
42
|
+
: value.shape.vertices[0] ?? { latitude: 0, longitude: 0 },
|
|
43
|
+
radiusMeters: 200,
|
|
44
|
+
},
|
|
45
|
+
})), button("Draw boundary", () => onChange({ ...value, shape: { kind: "polygon", vertices: [] } }))] }), value.shape.kind === "circle"
|
|
46
|
+
? (_jsxs(_Fragment, { children: [_jsx(Text, { style: styles.label, children: units === "imperial" ? "Radius in feet" : "Radius in metres" }), _jsx(TextInput, { accessibilityLabel: units === "imperial"
|
|
47
|
+
? "Radius in feet"
|
|
48
|
+
: "Radius in metres", keyboardType: "numeric", value: String(Math.round(value.shape.radiusMeters /
|
|
49
|
+
(units === "imperial" ? 0.3048 : 1))), onChangeText: (text) => {
|
|
50
|
+
if (value.shape.kind === "circle") {
|
|
51
|
+
onChange({
|
|
52
|
+
...value,
|
|
53
|
+
shape: {
|
|
54
|
+
...value.shape,
|
|
55
|
+
radiusMeters: Number(text) *
|
|
56
|
+
(units === "imperial" ? 0.3048 : 1),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}, style: styles.input }), _jsx(Text, { style: styles.help, children: "Tap the map to move the centre." })] }))
|
|
61
|
+
: (_jsxs(_Fragment, { children: [_jsxs(Text, { style: styles.help, children: ["Tap the map to add boundary points.", " ", value.shape.vertices.length, " points."] }), button("Undo last point", () => {
|
|
62
|
+
if (value.shape.kind === "polygon") {
|
|
63
|
+
onChange({
|
|
64
|
+
...value,
|
|
65
|
+
shape: {
|
|
66
|
+
...value.shape,
|
|
67
|
+
vertices: value.shape.vertices.slice(0, -1),
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
})] })), ["onEnter", "onExit", "enabled"].map((key) => (_jsxs(View, { style: styles.row, children: [_jsx(Text, { style: styles.label, children: key === "onEnter"
|
|
72
|
+
? "Notify on arrival"
|
|
73
|
+
: key === "onExit"
|
|
74
|
+
? "Notify on departure"
|
|
75
|
+
: "Area alerts enabled" }), _jsx(Switch, { accessibilityLabel: key === "onEnter"
|
|
76
|
+
? "Notify on arrival"
|
|
77
|
+
: key === "onExit"
|
|
78
|
+
? "Notify on departure"
|
|
79
|
+
: "Area alerts enabled", value: value[key], onValueChange: (v) => onChange({ ...value, [key]: v }), trackColor: { true: accent } })] }, key))), _jsxs(View, { style: styles.row, children: [_jsx(Text, { style: styles.label, children: "Start departure alerts after the first arrival" }), _jsx(Switch, { accessibilityLabel: "Start departure alerts after the first arrival", value: rules.armOnEntry, onValueChange: armOnEntry => changeRules({ armOnEntry }) })] }), scheduled && _jsxs(View, { style: styles.row, children: [_jsx(Text, { style: styles.label, children: "Use a schedule" }), _jsx(Switch, { accessibilityLabel: "Use a schedule", value: !!schedule, onValueChange: enabled => changeRules({ schedule: enabled ? { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, weekdays: [1, 2, 3, 4, 5], start: '08:00', end: '18:00', arriveBy: null, leaveBy: null } : null }) })] }), scheduled && schedule && _jsxs(_Fragment, { children: [_jsx(Text, { style: styles.label, children: "Time zone" }), _jsx(TextInput, { accessibilityLabel: "Time zone", style: styles.input, value: schedule.timeZone, autoCapitalize: "none", onChangeText: timeZone => changeSchedule({ timeZone }) }), _jsx(View, { style: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 }, children: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d, i) => _jsx(Pressable, { accessibilityRole: "checkbox", accessibilityState: { checked: schedule.weekdays.includes(i) }, onPress: () => changeSchedule({ weekdays: schedule.weekdays.includes(i) ? schedule.weekdays.filter(n => n !== i) : [...schedule.weekdays, i] }), style: [styles.button, { backgroundColor: schedule.weekdays.includes(i) ? '#e7eddf' : '#fff' }], children: _jsx(Text, { children: d }) }, d)) }), ['start', 'end', 'arriveBy', 'leaveBy'].map((key) => _jsxs(View, { children: [_jsx(Text, { style: styles.label, children: { start: 'Alerts from', end: 'Until', arriveBy: 'Expected arrival (optional)', leaveBy: 'Expected departure (optional)' }[key] }), _jsx(TextInput, { accessibilityLabel: { start: 'Alerts from', end: 'Until', arriveBy: 'Expected arrival', leaveBy: 'Expected departure' }[key], style: styles.input, value: schedule[key] ?? '', placeholder: "HH:MM", maxLength: 5, onChangeText: v => changeSchedule({ [key]: v || (['arriveBy', 'leaveBy'].includes(key) ? null : '') }) })] }, key)), _jsx(Text, { style: styles.help, children: "Use 24-hour times in the selected time zone. Stale location is reported as unavailable, never as a missed arrival or departure." })] }), error && (_jsx(Text, { accessibilityRole: "alert", style: { color: "#a32e25" }, children: error }))] })] }));
|
|
80
|
+
}
|
|
81
|
+
const styles = StyleSheet.create({
|
|
82
|
+
screen: { flex: 1, backgroundColor: "#fff" },
|
|
83
|
+
header: { flexDirection: "row", alignItems: "center", paddingHorizontal: 12 },
|
|
84
|
+
heading: {
|
|
85
|
+
fontSize: 20,
|
|
86
|
+
fontWeight: "600",
|
|
87
|
+
flex: 1,
|
|
88
|
+
textAlign: "center",
|
|
89
|
+
color: "#22291f",
|
|
90
|
+
},
|
|
91
|
+
button: { minHeight: 44, justifyContent: "center", padding: 12 },
|
|
92
|
+
map: { flex: 1, minHeight: 160 },
|
|
93
|
+
form: { maxHeight: "52%" },
|
|
94
|
+
label: { fontSize: 15, color: "#30392a", flex: 1 },
|
|
95
|
+
input: {
|
|
96
|
+
borderWidth: 1,
|
|
97
|
+
borderColor: "#dce0d8",
|
|
98
|
+
borderRadius: 10,
|
|
99
|
+
padding: 12,
|
|
100
|
+
color: "#22291f",
|
|
101
|
+
fontSize: 16,
|
|
102
|
+
marginVertical: 8,
|
|
103
|
+
},
|
|
104
|
+
row: {
|
|
105
|
+
flexDirection: "row",
|
|
106
|
+
alignItems: "center",
|
|
107
|
+
justifyContent: "space-between",
|
|
108
|
+
gap: 12,
|
|
109
|
+
marginVertical: 8,
|
|
110
|
+
},
|
|
111
|
+
help: { fontSize: 14, color: "#697061", marginVertical: 6 },
|
|
112
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type MotionReading, type LocationSettings, type Position } from "@robono/location";
|
|
3
|
+
export interface LocationScreenProps {
|
|
4
|
+
settings: LocationSettings;
|
|
5
|
+
map: ReactNode;
|
|
6
|
+
name: string;
|
|
7
|
+
mode?: "pin" | "live" | "ongoing";
|
|
8
|
+
position?: Position | null;
|
|
9
|
+
status: "waiting" | "current" | "stale" | "ended";
|
|
10
|
+
sharing?: boolean;
|
|
11
|
+
expiresAt?: string;
|
|
12
|
+
address?: string;
|
|
13
|
+
onShare?: (mode: "pin" | "live" | "ongoing", minutes: number) => Promise<void>;
|
|
14
|
+
onStop?: () => Promise<void>;
|
|
15
|
+
onDirections?: () => void;
|
|
16
|
+
onAreas?: () => void;
|
|
17
|
+
onHistory?: () => void;
|
|
18
|
+
onBack?: () => void;
|
|
19
|
+
onRefine?: () => Promise<void>;
|
|
20
|
+
motion?: MotionReading | null;
|
|
21
|
+
motionSharing?: boolean;
|
|
22
|
+
onMotionSharing?: (enabled: boolean) => Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export declare function LocationScreen({ settings: s, map, name, mode, position, status, sharing, expiresAt, address, onShare, onStop, onDirections, onAreas, onHistory, onBack, onRefine, motion, motionSharing, onMotionSharing, }: LocationScreenProps): import("react").JSX.Element;
|
|
25
|
+
export declare function LocationMessage({ title, detail, onOpen, accent }: {
|
|
26
|
+
title?: string;
|
|
27
|
+
detail: string;
|
|
28
|
+
onOpen: () => void;
|
|
29
|
+
accent?: string;
|
|
30
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Modal, Pressable, ScrollView, StyleSheet, Text, View, } from "react-native";
|
|
4
|
+
import Ionicons from "./visual/RobonoIcon";
|
|
5
|
+
import { formatSpeed, motionLabel, locationAccuracyLabel, } from "@robono/location";
|
|
6
|
+
export function LocationScreen({ settings: s, map, name, mode = "live", position, status, sharing, expiresAt, address, onShare, onStop, onDirections, onAreas, onHistory, onBack, onRefine, motion, motionSharing, onMotionSharing, }) {
|
|
7
|
+
const [open, setOpen] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState(""), [minutes, setMinutes] = useState(Math.min(60, s.maxShareMinutes)), [now, setNow] = useState(Date.now());
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const t = setInterval(() => setNow(Date.now()), 15000);
|
|
10
|
+
return () => clearInterval(t);
|
|
11
|
+
}, []);
|
|
12
|
+
useEffect(() => setMinutes((v) => Math.min(v, s.maxShareMinutes)), [
|
|
13
|
+
s.maxShareMinutes,
|
|
14
|
+
]);
|
|
15
|
+
const ended = status === "ended" ||
|
|
16
|
+
!!expiresAt && Date.parse(expiresAt) <= now, stale = mode !== "pin" &&
|
|
17
|
+
(status === "stale" ||
|
|
18
|
+
!!position &&
|
|
19
|
+
now - Date.parse(position.capturedAt) > s.staleAfterSeconds * 1000);
|
|
20
|
+
async function act(fn) {
|
|
21
|
+
if (busy)
|
|
22
|
+
return;
|
|
23
|
+
setBusy(true);
|
|
24
|
+
setError("");
|
|
25
|
+
try {
|
|
26
|
+
await fn();
|
|
27
|
+
setOpen(false);
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
setError(e instanceof Error ? e.message : "Please try again.");
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
setBusy(false);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const button = (label, press, primary = false) => (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: label, disabled: busy, onPress: press, style: [
|
|
37
|
+
styles.button,
|
|
38
|
+
primary && { backgroundColor: s.appearance.accent },
|
|
39
|
+
busy && { opacity: .5 },
|
|
40
|
+
], children: _jsx(Text, { style: [styles.buttonText, primary && { color: "#fff" }], children: label }) }));
|
|
41
|
+
return (_jsxs(View, { style: styles.screen, children: [_jsxs(View, { style: styles.header, children: [onBack && (_jsx(Pressable, { accessibilityLabel: "Back", accessibilityRole: "button", onPress: onBack, hitSlop: 12, children: _jsx(Ionicons, { name: "arrow-back", size: 26, color: "#263022" }) })), _jsx(Text, { style: styles.title, children: s.appearance.title })] }), _jsxs(View, { style: styles.map, children: [ended
|
|
42
|
+
? (_jsx(View, { style: {
|
|
43
|
+
flex: 1,
|
|
44
|
+
alignItems: "center",
|
|
45
|
+
justifyContent: "center",
|
|
46
|
+
backgroundColor: "#f1f3ee",
|
|
47
|
+
}, children: _jsx(Text, { style: styles.secondary, children: "Location sharing has ended." }) }))
|
|
48
|
+
: map, _jsxs(View, { style: styles.mapActions, children: [s.features.geofences && onAreas && button("Areas", onAreas), s.features.history && onHistory && button("History", onHistory)] })] }), _jsxs(View, { style: styles.details, children: [_jsx(Text, { style: styles.name, children: name }), _jsx(Text, { style: styles.secondary, children: ended
|
|
49
|
+
? "Sharing ended"
|
|
50
|
+
: !position
|
|
51
|
+
? "Waiting for location"
|
|
52
|
+
: mode === "pin"
|
|
53
|
+
? "Shared location"
|
|
54
|
+
: stale
|
|
55
|
+
? "Location may be out of date"
|
|
56
|
+
: "Location updated just now" }), address && _jsx(Text, { style: styles.address, children: address }), position && !ended && (_jsxs(Text, { style: styles.meta, children: [s.appearance.showSpeed
|
|
57
|
+
? formatSpeed(position.speedMps, s.units) + " · "
|
|
58
|
+
: "", locationAccuracyLabel(position, s.units), s.appearance.showHeading && position.headingDegrees != null
|
|
59
|
+
? ` · Heading ${Math.round(position.headingDegrees)}°`
|
|
60
|
+
: ""] })), s.features.motion && !ended && _jsx(Text, { style: styles.meta, children: motionLabel(motion, now) }), s.features.motion && onMotionSharing && !ended && button(motionSharing ? 'Stop sharing movement' : 'Share device movement', () => void act(() => onMotionSharing(!motionSharing))), s.features.precise && onRefine && !ended && button(busy ? 'Refining location…' : 'Improve location accuracy', () => void act(onRefine)), sharing && !ended && expiresAt && (_jsxs(Text, { style: styles.meta, children: ["Sharing until ", new Date(expiresAt).toLocaleTimeString([], {
|
|
61
|
+
hour: "numeric",
|
|
62
|
+
minute: "2-digit",
|
|
63
|
+
})] })), _jsxs(View, { style: styles.actions, children: [sharing && !ended && onStop
|
|
64
|
+
? button("Stop sharing", () => void act(onStop))
|
|
65
|
+
: onShare &&
|
|
66
|
+
(s.features.pin || s.features.live || s.features.ongoing)
|
|
67
|
+
? button("Share location", () => setOpen(true), true)
|
|
68
|
+
: null, s.features.directions && !ended && position && onDirections &&
|
|
69
|
+
button("Directions", onDirections)] }), error && !open && (_jsx(Text, { accessibilityRole: "alert", style: styles.error, children: error }))] }), _jsx(Modal, { transparent: true, visible: open, animationType: "slide", onRequestClose: () => {
|
|
70
|
+
if (!busy)
|
|
71
|
+
setOpen(false);
|
|
72
|
+
}, children: _jsx(View, { style: styles.scrim, children: _jsxs(ScrollView, { style: styles.sheet, contentContainerStyle: { padding: 24 }, children: [_jsx(Text, { style: styles.title, children: "Share location" }), _jsx(Text, { style: styles.secondary, children: "Only the people you choose can see your location." }), s.features.pin && button("Send this location", () => void act(() => onShare("pin", minutes))), s.features.live && (_jsxs(_Fragment, { children: [_jsx(Text, { style: styles.address, children: "Share live for" }), _jsx(View, { style: styles.actions, children: [
|
|
73
|
+
...new Set([15, 60, 480, s.maxShareMinutes].filter((n) => n <= s.maxShareMinutes)),
|
|
74
|
+
].sort((a, b) => a - b).map((n) => (_jsx(Pressable, { accessibilityRole: "radio", accessibilityState: { checked: n === minutes }, onPress: () => setMinutes(n), style: [
|
|
75
|
+
styles.button,
|
|
76
|
+
n === minutes && { borderColor: s.appearance.accent },
|
|
77
|
+
], children: _jsx(Text, { children: n < 60 ? `${n} min` : `${n / 60} hours` }) }, n))) }), button("Share live location", () => void act(() => onShare("live", minutes)), true)] })), s.features.ongoing && button("Keep sharing until I stop", () => void act(() => onShare("ongoing", minutes))), error && (_jsx(Text, { accessibilityRole: "alert", style: styles.error, children: error })), button("Cancel", () => setOpen(false))] }) }) })] }));
|
|
78
|
+
}
|
|
79
|
+
export function LocationMessage({ title = "Shared location", detail, onOpen, accent = "#b75319" }) {
|
|
80
|
+
return (_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `${title}. ${detail}. Open map`, onPress: onOpen, style: styles.message, children: [_jsx(Ionicons, { name: "location-outline", size: 28, color: accent }), _jsx(Text, { style: styles.messageTitle, children: title }), _jsx(Text, { style: styles.meta, children: detail }), _jsx(Text, { style: [styles.meta, { color: accent, marginTop: 12 }], children: "Open map \u2192" })] }));
|
|
81
|
+
}
|
|
82
|
+
const styles = StyleSheet.create({
|
|
83
|
+
screen: { flex: 1, backgroundColor: "#fff" },
|
|
84
|
+
header: {
|
|
85
|
+
flexDirection: "row",
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
gap: 12,
|
|
88
|
+
paddingHorizontal: 20,
|
|
89
|
+
paddingVertical: 16,
|
|
90
|
+
},
|
|
91
|
+
title: { fontSize: 20, fontWeight: "600", color: "#22291f" },
|
|
92
|
+
map: { flex: 1, minHeight: 100 },
|
|
93
|
+
mapActions: {
|
|
94
|
+
position: "absolute",
|
|
95
|
+
top: 10,
|
|
96
|
+
left: 10,
|
|
97
|
+
flexDirection: "row",
|
|
98
|
+
gap: 8,
|
|
99
|
+
},
|
|
100
|
+
details: { padding: 20, borderTopWidth: 1, borderColor: "#e4e7e0" },
|
|
101
|
+
name: { fontSize: 19, fontWeight: "600", color: "#22291f" },
|
|
102
|
+
secondary: { fontSize: 14, color: "#627259", marginVertical: 8 },
|
|
103
|
+
address: { fontSize: 14, color: "#333d2e", marginVertical: 8 },
|
|
104
|
+
meta: { fontSize: 13, color: "#697061", marginTop: 6 },
|
|
105
|
+
actions: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 12 },
|
|
106
|
+
button: {
|
|
107
|
+
minHeight: 44,
|
|
108
|
+
paddingHorizontal: 18,
|
|
109
|
+
paddingVertical: 12,
|
|
110
|
+
borderWidth: 1,
|
|
111
|
+
borderColor: "#dce0d8",
|
|
112
|
+
borderRadius: 24,
|
|
113
|
+
backgroundColor: "#fff",
|
|
114
|
+
alignItems: "center",
|
|
115
|
+
justifyContent: "center",
|
|
116
|
+
marginVertical: 3,
|
|
117
|
+
},
|
|
118
|
+
buttonText: { fontSize: 15, color: "#252c22" },
|
|
119
|
+
error: { color: "#a32e25", marginTop: 12 },
|
|
120
|
+
scrim: { flex: 1, backgroundColor: "#0006", justifyContent: "flex-end" },
|
|
121
|
+
sheet: {
|
|
122
|
+
maxHeight: "85%",
|
|
123
|
+
backgroundColor: "#fff",
|
|
124
|
+
borderTopLeftRadius: 24,
|
|
125
|
+
borderTopRightRadius: 24,
|
|
126
|
+
},
|
|
127
|
+
message: {
|
|
128
|
+
width: 250,
|
|
129
|
+
maxWidth: "100%",
|
|
130
|
+
borderRadius: 18,
|
|
131
|
+
padding: 16,
|
|
132
|
+
backgroundColor: "#f0f3eb",
|
|
133
|
+
borderColor: "#dce0d8",
|
|
134
|
+
borderWidth: 1,
|
|
135
|
+
},
|
|
136
|
+
messageTitle: {
|
|
137
|
+
fontSize: 16,
|
|
138
|
+
fontWeight: "600",
|
|
139
|
+
color: "#242a20",
|
|
140
|
+
marginTop: 8,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Coordinate, FenceShape } from "@robono/location";
|
|
2
|
+
export declare function RobonoMapView({ center, markers, fences, route, accent, mapType, onSelect, onLoad, }: {
|
|
3
|
+
center: Coordinate;
|
|
4
|
+
markers?: {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
position: Coordinate;
|
|
8
|
+
}[];
|
|
9
|
+
fences?: FenceShape[];
|
|
10
|
+
route?: Coordinate[];
|
|
11
|
+
accent?: string;
|
|
12
|
+
mapType?: "roadmap" | "satellite";
|
|
13
|
+
onSelect?: (p: Coordinate) => void;
|
|
14
|
+
onLoad?: () => void;
|
|
15
|
+
}): import("react").JSX.Element;
|
package/dist/MapView.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import MapView, { Circle, Marker, Polygon, Polyline, PROVIDER_GOOGLE, } from "react-native-maps";
|
|
4
|
+
export function RobonoMapView({ center, markers = [], fences = [], route = [], accent = "#b75319", mapType = "roadmap", onSelect, onLoad, }) {
|
|
5
|
+
const map = useRef(null);
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
map.current?.animateToRegion({
|
|
8
|
+
...center,
|
|
9
|
+
latitudeDelta: .012,
|
|
10
|
+
longitudeDelta: .012,
|
|
11
|
+
}, 350);
|
|
12
|
+
}, [center.latitude, center.longitude]);
|
|
13
|
+
return (_jsxs(MapView, { onMapReady: onLoad, ref: map, provider: PROVIDER_GOOGLE, style: { flex: 1 }, initialRegion: { ...center, latitudeDelta: .012, longitudeDelta: .012 }, mapType: mapType === "roadmap" ? "standard" : "satellite", showsUserLocation: false, showsMyLocationButton: false, showsCompass: true, onPress: (e) => onSelect?.(e.nativeEvent.coordinate), children: [markers.map((m) => (_jsx(Marker, { coordinate: m.position, title: m.label, pinColor: accent }, m.id))), fences.map((f, i) => f.kind === "circle"
|
|
14
|
+
? (_jsx(Circle, { center: f.center, radius: f.radiusMeters, strokeColor: accent, fillColor: accent + "22", strokeWidth: 2 }, i))
|
|
15
|
+
: (_jsx(Polygon, { coordinates: f.vertices, strokeColor: accent, fillColor: accent + "22", strokeWidth: 2 }, i))), !!route.length && (_jsx(Polyline, { coordinates: route, strokeColor: accent, strokeWidth: 5 }))] }));
|
|
16
|
+
}
|