@upstep/react-native 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/package.json +38 -0
- package/src/FeedbackButton.tsx +42 -0
- package/src/FeedbackContext.tsx +72 -0
- package/src/FeedbackSheet.tsx +179 -0
- package/src/api.ts +52 -0
- package/src/index.ts +5 -0
- package/src/useShakeToFeedback.ts +42 -0
- package/tsconfig.json +9 -0
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@upstep/react-native",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Upstep feedback SDK for React Native",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"types": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./src/index.ts",
|
|
10
|
+
"require": "./src/index.ts",
|
|
11
|
+
"types": "./src/index.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@upstep/types": "0.0.1"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@gorhom/bottom-sheet": ">=4",
|
|
19
|
+
"react": ">=18",
|
|
20
|
+
"react-native": ">=0.73",
|
|
21
|
+
"react-native-gesture-handler": ">=2",
|
|
22
|
+
"react-native-reanimated": ">=3"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/react": "^19.0.7",
|
|
26
|
+
"@types/react-native": "^0.73.0",
|
|
27
|
+
"typescript": "^5.7.3"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"react-native",
|
|
31
|
+
"feedback",
|
|
32
|
+
"upstep"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"scripts": {
|
|
36
|
+
"type-check": "tsc --noEmit"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { TouchableOpacity, Text, StyleSheet } from "react-native";
|
|
3
|
+
import { useUpstep } from "./FeedbackContext";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
position?: "bottom-right" | "bottom-left";
|
|
7
|
+
label?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function FeedbackButton({ position = "bottom-right", label = "Feedback" }: Props) {
|
|
11
|
+
const { openSheet } = useUpstep();
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<TouchableOpacity
|
|
15
|
+
onPress={openSheet}
|
|
16
|
+
style={[styles.btn, position === "bottom-left" ? styles.left : styles.right]}
|
|
17
|
+
activeOpacity={0.85}
|
|
18
|
+
>
|
|
19
|
+
<Text style={styles.label}>{label}</Text>
|
|
20
|
+
</TouchableOpacity>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const styles = StyleSheet.create({
|
|
25
|
+
btn: {
|
|
26
|
+
position: "absolute",
|
|
27
|
+
bottom: 24,
|
|
28
|
+
backgroundColor: "#6366f1",
|
|
29
|
+
borderRadius: 9999,
|
|
30
|
+
paddingVertical: 12,
|
|
31
|
+
paddingHorizontal: 20,
|
|
32
|
+
shadowColor: "#000",
|
|
33
|
+
shadowOffset: { width: 0, height: 4 },
|
|
34
|
+
shadowOpacity: 0.2,
|
|
35
|
+
shadowRadius: 6,
|
|
36
|
+
elevation: 6,
|
|
37
|
+
zIndex: 100,
|
|
38
|
+
},
|
|
39
|
+
right: { right: 20 },
|
|
40
|
+
left: { left: 20 },
|
|
41
|
+
label: { color: "#fff", fontSize: 14, fontWeight: "600" },
|
|
42
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useState,
|
|
6
|
+
useCallback,
|
|
7
|
+
useRef,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
import type { Feedback, FeedbackType, UpstepConfig, VoteValue } from "@upstep/types";
|
|
11
|
+
import { UpstepApiClient } from "./api";
|
|
12
|
+
import type BottomSheet from "@gorhom/bottom-sheet";
|
|
13
|
+
|
|
14
|
+
interface UpstepContextValue {
|
|
15
|
+
client: UpstepApiClient;
|
|
16
|
+
feedItems: Feedback[];
|
|
17
|
+
loadFeed: () => Promise<void>;
|
|
18
|
+
submit: (content: string, type?: FeedbackType) => Promise<void>;
|
|
19
|
+
vote: (feedbackId: string, value: VoteValue) => Promise<void>;
|
|
20
|
+
openSheet: () => void;
|
|
21
|
+
sheetRef: React.RefObject<BottomSheet | null>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const UpstepContext = createContext<UpstepContextValue | null>(null);
|
|
25
|
+
|
|
26
|
+
export function FeedbackProvider({
|
|
27
|
+
children,
|
|
28
|
+
...config
|
|
29
|
+
}: UpstepConfig & { children: ReactNode }) {
|
|
30
|
+
const [client] = useState(() => new UpstepApiClient(config));
|
|
31
|
+
const [feedItems, setFeedItems] = useState<Feedback[]>([]);
|
|
32
|
+
const sheetRef = useRef<BottomSheet>(null);
|
|
33
|
+
|
|
34
|
+
const loadFeed = useCallback(async () => {
|
|
35
|
+
try {
|
|
36
|
+
const data = await client.listFeedback({ sort: "votes", limit: 20 });
|
|
37
|
+
setFeedItems(data.items);
|
|
38
|
+
} catch {
|
|
39
|
+
// non-critical
|
|
40
|
+
}
|
|
41
|
+
}, [client]);
|
|
42
|
+
|
|
43
|
+
useEffect(() => { loadFeed(); }, [loadFeed]);
|
|
44
|
+
|
|
45
|
+
const submit = useCallback(async (content: string, type?: FeedbackType) => {
|
|
46
|
+
const payload: Parameters<typeof client.submitFeedback>[0] = { content };
|
|
47
|
+
if (type !== undefined) payload.type = type;
|
|
48
|
+
await client.submitFeedback(payload);
|
|
49
|
+
await loadFeed();
|
|
50
|
+
}, [client, loadFeed]);
|
|
51
|
+
|
|
52
|
+
const vote = useCallback(async (feedbackId: string, value: VoteValue) => {
|
|
53
|
+
await client.vote(feedbackId, value);
|
|
54
|
+
await loadFeed();
|
|
55
|
+
}, [client, loadFeed]);
|
|
56
|
+
|
|
57
|
+
const openSheet = useCallback(() => {
|
|
58
|
+
sheetRef.current?.expand();
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<UpstepContext.Provider value={{ client, feedItems, loadFeed, submit, vote, openSheet, sheetRef }}>
|
|
63
|
+
{children}
|
|
64
|
+
</UpstepContext.Provider>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function useUpstep(): UpstepContextValue {
|
|
69
|
+
const ctx = useContext(UpstepContext);
|
|
70
|
+
if (!ctx) throw new Error("useUpstep must be used inside <FeedbackProvider>");
|
|
71
|
+
return ctx;
|
|
72
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import React, { useState, useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
TextInput,
|
|
6
|
+
TouchableOpacity,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
ScrollView,
|
|
9
|
+
ActivityIndicator,
|
|
10
|
+
} from "react-native";
|
|
11
|
+
import BottomSheet, { BottomSheetView, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
|
12
|
+
import type { Feedback, FeedbackType } from "@upstep/types";
|
|
13
|
+
import { useUpstep } from "./FeedbackContext";
|
|
14
|
+
|
|
15
|
+
type Tab = "submit" | "feed";
|
|
16
|
+
|
|
17
|
+
const TYPES: FeedbackType[] = ["BUG", "FEATURE", "GENERAL"];
|
|
18
|
+
|
|
19
|
+
export function FeedbackSheet() {
|
|
20
|
+
const { feedItems, submit, vote, sheetRef } = useUpstep();
|
|
21
|
+
const snapPoints = useMemo(() => ["75%", "95%"], []);
|
|
22
|
+
const [tab, setTab] = useState<Tab>("submit");
|
|
23
|
+
const [content, setContent] = useState("");
|
|
24
|
+
const [type, setType] = useState<FeedbackType>("GENERAL");
|
|
25
|
+
const [msg, setMsg] = useState("");
|
|
26
|
+
const [loading, setLoading] = useState(false);
|
|
27
|
+
|
|
28
|
+
async function handleSubmit() {
|
|
29
|
+
if (!content.trim() || loading) return;
|
|
30
|
+
setLoading(true);
|
|
31
|
+
try {
|
|
32
|
+
await submit(content.trim(), type);
|
|
33
|
+
setContent("");
|
|
34
|
+
setMsg("Thanks for your feedback!");
|
|
35
|
+
} catch {
|
|
36
|
+
setMsg("Something went wrong. Please try again.");
|
|
37
|
+
} finally {
|
|
38
|
+
setLoading(false);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<BottomSheet
|
|
44
|
+
ref={sheetRef}
|
|
45
|
+
index={-1}
|
|
46
|
+
snapPoints={snapPoints}
|
|
47
|
+
enablePanDownToClose
|
|
48
|
+
backgroundStyle={styles.sheet}
|
|
49
|
+
handleIndicatorStyle={styles.handle}
|
|
50
|
+
>
|
|
51
|
+
<BottomSheetView style={styles.header}>
|
|
52
|
+
<Text style={styles.title}>Share feedback</Text>
|
|
53
|
+
</BottomSheetView>
|
|
54
|
+
|
|
55
|
+
<View style={styles.tabs}>
|
|
56
|
+
{(["submit", "feed"] as Tab[]).map((t) => (
|
|
57
|
+
<TouchableOpacity
|
|
58
|
+
key={t}
|
|
59
|
+
onPress={() => setTab(t)}
|
|
60
|
+
style={[styles.tab, tab === t && styles.activeTab]}
|
|
61
|
+
>
|
|
62
|
+
<Text style={[styles.tabText, tab === t && styles.activeTabText]}>
|
|
63
|
+
{t === "submit" ? "Submit" : "Votes & Ideas"}
|
|
64
|
+
</Text>
|
|
65
|
+
</TouchableOpacity>
|
|
66
|
+
))}
|
|
67
|
+
</View>
|
|
68
|
+
|
|
69
|
+
{tab === "submit" ? (
|
|
70
|
+
<BottomSheetView style={styles.body}>
|
|
71
|
+
<TextInput
|
|
72
|
+
value={content}
|
|
73
|
+
onChangeText={setContent}
|
|
74
|
+
placeholder="What's on your mind?"
|
|
75
|
+
placeholderTextColor="#9ca3af"
|
|
76
|
+
multiline
|
|
77
|
+
maxLength={2000}
|
|
78
|
+
style={styles.textarea}
|
|
79
|
+
/>
|
|
80
|
+
<View style={styles.typeRow}>
|
|
81
|
+
{TYPES.map((t) => (
|
|
82
|
+
<TouchableOpacity
|
|
83
|
+
key={t}
|
|
84
|
+
onPress={() => setType(t)}
|
|
85
|
+
style={[styles.typeBtn, type === t && styles.selectedTypeBtn]}
|
|
86
|
+
>
|
|
87
|
+
<Text style={[styles.typeBtnText, type === t && styles.selectedTypeBtnText]}>
|
|
88
|
+
{t.charAt(0) + t.slice(1).toLowerCase()}
|
|
89
|
+
</Text>
|
|
90
|
+
</TouchableOpacity>
|
|
91
|
+
))}
|
|
92
|
+
</View>
|
|
93
|
+
<TouchableOpacity
|
|
94
|
+
onPress={handleSubmit}
|
|
95
|
+
disabled={loading || !content.trim()}
|
|
96
|
+
style={[styles.submitBtn, (loading || !content.trim()) && styles.disabledBtn]}
|
|
97
|
+
>
|
|
98
|
+
{loading ? (
|
|
99
|
+
<ActivityIndicator color="#fff" />
|
|
100
|
+
) : (
|
|
101
|
+
<Text style={styles.submitBtnText}>Send feedback</Text>
|
|
102
|
+
)}
|
|
103
|
+
</TouchableOpacity>
|
|
104
|
+
{msg ? <Text style={styles.msgText}>{msg}</Text> : null}
|
|
105
|
+
</BottomSheetView>
|
|
106
|
+
) : (
|
|
107
|
+
<BottomSheetScrollView contentContainerStyle={styles.body}>
|
|
108
|
+
{feedItems.length === 0 ? (
|
|
109
|
+
<Text style={styles.emptyText}>No feedback yet. Be the first!</Text>
|
|
110
|
+
) : (
|
|
111
|
+
feedItems.map((f) => (
|
|
112
|
+
<FeedItem key={f.id} item={f} onVote={vote} />
|
|
113
|
+
))
|
|
114
|
+
)}
|
|
115
|
+
</BottomSheetScrollView>
|
|
116
|
+
)}
|
|
117
|
+
</BottomSheet>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function FeedItem({ item, onVote }: { item: Feedback; onVote: (id: string, v: "UP" | "DOWN") => void }) {
|
|
122
|
+
return (
|
|
123
|
+
<View style={styles.feedItem}>
|
|
124
|
+
<Text style={styles.feedContent}>{item.content}</Text>
|
|
125
|
+
<View style={styles.feedMeta}>
|
|
126
|
+
<View style={[styles.badge, item.type === "BUG" ? styles.badgeBug : item.type === "FEATURE" ? styles.badgeFeature : styles.badgeGeneral]}>
|
|
127
|
+
<Text style={[styles.badgeText, item.type === "BUG" ? { color: "#dc2626" } : item.type === "FEATURE" ? { color: "#2563eb" } : { color: "#6b7280" }]}>
|
|
128
|
+
{item.type}
|
|
129
|
+
</Text>
|
|
130
|
+
</View>
|
|
131
|
+
<Text style={styles.feedDate}>{new Date(item.createdAt).toLocaleDateString()}</Text>
|
|
132
|
+
</View>
|
|
133
|
+
<View style={styles.voteRow}>
|
|
134
|
+
<TouchableOpacity style={styles.voteBtn} onPress={() => onVote(item.id, "UP")}>
|
|
135
|
+
<Text style={styles.voteBtnText}>▲ {item.upvotes}</Text>
|
|
136
|
+
</TouchableOpacity>
|
|
137
|
+
<TouchableOpacity style={styles.voteBtn} onPress={() => onVote(item.id, "DOWN")}>
|
|
138
|
+
<Text style={styles.voteBtnText}>▼ {item.downvotes}</Text>
|
|
139
|
+
</TouchableOpacity>
|
|
140
|
+
</View>
|
|
141
|
+
</View>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const styles = StyleSheet.create({
|
|
146
|
+
sheet: { backgroundColor: "#fff", borderRadius: 16 },
|
|
147
|
+
handle: { backgroundColor: "#d1d5db", width: 36, height: 4 },
|
|
148
|
+
header: { paddingHorizontal: 20, paddingTop: 8, paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: "#e5e7eb" },
|
|
149
|
+
title: { fontSize: 16, fontWeight: "700", color: "#111827" },
|
|
150
|
+
tabs: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: "#e5e7eb" },
|
|
151
|
+
tab: { flex: 1, paddingVertical: 12, alignItems: "center", borderBottomWidth: 2, borderBottomColor: "transparent" },
|
|
152
|
+
activeTab: { borderBottomColor: "#6366f1" },
|
|
153
|
+
tabText: { fontSize: 13, fontWeight: "500", color: "#6b7280" },
|
|
154
|
+
activeTabText: { color: "#6366f1" },
|
|
155
|
+
body: { padding: 16 },
|
|
156
|
+
textarea: { borderWidth: 1, borderColor: "#d1d5db", borderRadius: 8, padding: 10, fontSize: 14, minHeight: 100, textAlignVertical: "top", color: "#111827" },
|
|
157
|
+
typeRow: { flexDirection: "row", gap: 8, marginVertical: 10 },
|
|
158
|
+
typeBtn: { paddingVertical: 5, paddingHorizontal: 12, borderRadius: 9999, borderWidth: 1, borderColor: "#d1d5db" },
|
|
159
|
+
selectedTypeBtn: { backgroundColor: "#6366f1", borderColor: "#6366f1" },
|
|
160
|
+
typeBtnText: { fontSize: 12, color: "#374151" },
|
|
161
|
+
selectedTypeBtnText: { color: "#fff" },
|
|
162
|
+
submitBtn: { backgroundColor: "#6366f1", borderRadius: 8, padding: 12, alignItems: "center", marginTop: 8 },
|
|
163
|
+
disabledBtn: { opacity: 0.5 },
|
|
164
|
+
submitBtnText: { color: "#fff", fontSize: 14, fontWeight: "600" },
|
|
165
|
+
msgText: { textAlign: "center", fontSize: 13, color: "#6b7280", marginTop: 8 },
|
|
166
|
+
emptyText: { textAlign: "center", color: "#9ca3af", fontSize: 14, paddingVertical: 32 },
|
|
167
|
+
feedItem: { paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: "#f3f4f6" },
|
|
168
|
+
feedContent: { fontSize: 14, color: "#1f2937", marginBottom: 6 },
|
|
169
|
+
feedMeta: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 },
|
|
170
|
+
badge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 9999 },
|
|
171
|
+
badgeBug: { backgroundColor: "#fee2e2" },
|
|
172
|
+
badgeFeature: { backgroundColor: "#dbeafe" },
|
|
173
|
+
badgeGeneral: { backgroundColor: "#f3f4f6" },
|
|
174
|
+
badgeText: { fontSize: 11, fontWeight: "500" },
|
|
175
|
+
feedDate: { fontSize: 11, color: "#9ca3af" },
|
|
176
|
+
voteRow: { flexDirection: "row", gap: 8 },
|
|
177
|
+
voteBtn: { flexDirection: "row", alignItems: "center", paddingHorizontal: 10, paddingVertical: 4, borderRadius: 9999, borderWidth: 1, borderColor: "#e5e7eb" },
|
|
178
|
+
voteBtnText: { fontSize: 12, color: "#374151" },
|
|
179
|
+
});
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Feedback,
|
|
3
|
+
FeedbackListResponse,
|
|
4
|
+
SubmitFeedbackPayload,
|
|
5
|
+
UpstepConfig,
|
|
6
|
+
VoteValue,
|
|
7
|
+
} from "@upstep/types";
|
|
8
|
+
|
|
9
|
+
export class UpstepApiClient {
|
|
10
|
+
private readonly baseUrl: string;
|
|
11
|
+
private readonly apiKey: string;
|
|
12
|
+
readonly userId?: string;
|
|
13
|
+
|
|
14
|
+
constructor(config: UpstepConfig) {
|
|
15
|
+
this.baseUrl = (config.baseUrl ?? "https://upstep.io").replace(/\/$/, "");
|
|
16
|
+
this.apiKey = config.apiKey;
|
|
17
|
+
if (config.userId !== undefined) this.userId = config.userId;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private headers(): Record<string, string> {
|
|
21
|
+
return { "Content-Type": "application/json", "x-api-key": this.apiKey };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async listFeedback(params?: { cursor?: string; limit?: number; sort?: "date" | "votes" }): Promise<FeedbackListResponse> {
|
|
25
|
+
const qs = new URLSearchParams();
|
|
26
|
+
if (params?.cursor) qs.set("cursor", params.cursor);
|
|
27
|
+
if (params?.limit) qs.set("limit", String(params.limit));
|
|
28
|
+
if (params?.sort) qs.set("sort", params.sort);
|
|
29
|
+
const res = await fetch(`${this.baseUrl}/api/sdk/feedback?${qs}`, { headers: this.headers() });
|
|
30
|
+
if (!res.ok) throw new Error(`Upstep: ${res.status}`);
|
|
31
|
+
return res.json() as Promise<FeedbackListResponse>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async submitFeedback(payload: SubmitFeedbackPayload): Promise<Feedback> {
|
|
35
|
+
const res = await fetch(`${this.baseUrl}/api/sdk/feedback`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: this.headers(),
|
|
38
|
+
body: JSON.stringify({ ...payload, endUserId: payload.endUserId ?? this.userId }),
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) throw new Error(`Upstep: ${res.status}`);
|
|
41
|
+
return res.json() as Promise<Feedback>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async vote(feedbackId: string, value: VoteValue): Promise<void> {
|
|
45
|
+
const res = await fetch(`${this.baseUrl}/api/sdk/feedback/${feedbackId}/vote`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: this.headers(),
|
|
48
|
+
body: JSON.stringify({ value, endUserId: this.userId }),
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) throw new Error(`Upstep: ${res.status}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { FeedbackProvider, useUpstep } from "./FeedbackContext";
|
|
2
|
+
export { FeedbackSheet } from "./FeedbackSheet";
|
|
3
|
+
export { FeedbackButton } from "./FeedbackButton";
|
|
4
|
+
export { useShakeToFeedback } from "./useShakeToFeedback";
|
|
5
|
+
export type { UpstepConfig, Feedback, FeedbackType, FeedbackStatus, VoteValue } from "@upstep/types";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useUpstep } from "./FeedbackContext";
|
|
3
|
+
|
|
4
|
+
// expo-sensors is a peer dependency when using Expo; skip if not available
|
|
5
|
+
let Accelerometer: {
|
|
6
|
+
setUpdateInterval: (ms: number) => void;
|
|
7
|
+
addListener: (cb: (data: { x: number; y: number; z: number }) => void) => { remove: () => void };
|
|
8
|
+
} | undefined;
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
12
|
+
Accelerometer = (require("expo-sensors") as { Accelerometer: typeof Accelerometer }).Accelerometer;
|
|
13
|
+
} catch {
|
|
14
|
+
// expo-sensors not installed — shake detection unavailable
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SHAKE_THRESHOLD = 2.5;
|
|
18
|
+
const COOLDOWN_MS = 2000;
|
|
19
|
+
|
|
20
|
+
export function useShakeToFeedback() {
|
|
21
|
+
const { openSheet } = useUpstep();
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!Accelerometer) return;
|
|
25
|
+
|
|
26
|
+
let lastShake = 0;
|
|
27
|
+
Accelerometer.setUpdateInterval(100);
|
|
28
|
+
|
|
29
|
+
const subscription = Accelerometer.addListener(({ x, y, z }) => {
|
|
30
|
+
const total = Math.sqrt(x * x + y * y + z * z);
|
|
31
|
+
if (total > SHAKE_THRESHOLD) {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
if (now - lastShake > COOLDOWN_MS) {
|
|
34
|
+
lastShake = now;
|
|
35
|
+
openSheet();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return () => subscription.remove();
|
|
41
|
+
}, [openSheet]);
|
|
42
|
+
}
|