@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/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;
@@ -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
+ }