@klyftig/mobile-ui 1.5.0 → 1.7.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/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # @klyftig/mobile-ui
2
+
3
+ Shared UI for Klyftig's React Native / Expo apps (Kaelo, Nagger, Sleepwise,
4
+ Clev, Stampic). It ships TypeScript source (`main: src/index.ts`), so each app's
5
+ Metro/Babel compiles it. Apps' jest configs must keep `@klyftig/.*` in
6
+ `transformIgnorePatterns`.
7
+
8
+ Exports: theme tokens (`colors`, `typography`, `spacing`, …), glass components
9
+ (`GlassButton`, `GlassCard`, `CenterDialog`, `WheelPicker`, …) and the
10
+ **review ("Rate Us") module** described below. See `CHANGELOG.md` for the
11
+ version history.
12
+
13
+ ## Peer dependencies
14
+
15
+ `expo`, `react`, `react-native`, `@expo/vector-icons`, `expo-blur`,
16
+ `expo-glass-effect`, `expo-linear-gradient`, `react-native-svg`,
17
+ `react-native-safe-area-context`, `expo-store-review` (native module, needs a
18
+ dev-client rebuild when added), and `zustand` (>= 5).
19
+
20
+ ## Review ("Rate Us") module
21
+
22
+ This is a sentiment gate. 4–5★ asks for a store review and 1–3★ opens a feedback
23
+ email. The prompt appears at milestone counts of a happy-moment event the app
24
+ chooses. Each app configures it once:
25
+
26
+ ```ts
27
+ // src/utils/storeLinks.ts — the ONE place the App Store id lives
28
+ export const STORE_TARGET: StoreTarget = { iosAppId: '6759951153', androidPackage: 'com.klyftig.kaelo' };
29
+
30
+ // src/features/review/review.ts
31
+ export const review = createReviewPrompt({
32
+ ...STORE_TARGET,
33
+ appName: 'Kaelo',
34
+ supportEmail: 'support@klyftig.co',
35
+ storageKey: 'review.prompt.v1', // keep stable once shipped
36
+ storage: AsyncStorage,
37
+ defaultMilestones: [3, 6], // optional, this is the default
38
+ haptics, // optional { selection() }
39
+ });
40
+ ```
41
+
42
+ Then:
43
+
44
+ - **Count** the happy moment with `review.recordEvent(reason?)`. For a once-ever
45
+ trigger (e.g. "2nd habit created"), use `review.recordOneTimeTrigger(id, reason?)`.
46
+ Both are async, hydrate first, and never throw.
47
+ - **Mount** the modal once, globally:
48
+ `<RateAppModal review={review} strings={reviewStringsFromT(t)} />`. Without
49
+ `strings` it shows English defaults built from `appName`. `strings.thankYou`
50
+ may be a `(reason) => string` function.
51
+ - **Surface** the prompt by calling `review.showIfPending()` from a screen where
52
+ a modal is safe (not over a sheet, a tour, the camera or a recording).
53
+ - **Remote config** goes through `review.configure({ enabled, milestones })`.
54
+ Invalid or missing fields keep the current value.
55
+ - **Tests** reset state with `review.resetForTests()`. Set
56
+ `review.useStore.setState({ visible: true })` to render the modal.
57
+
58
+ Store behaviour (`requestStoreReview`):
59
+
60
+ | Platform | Condition | Action |
61
+ |---|---|---|
62
+ | iOS | numeric `iosAppId` | `itms-apps://apps.apple.com/app/id<id>?action=write-review` (native prompt if it can't open) |
63
+ | iOS | unset / placeholder / any non-numeric id | `StoreReview.requestReview()` after `isAvailableAsync()`. No URL is ever opened |
64
+ | Android | always | Play In-App Review, then `market://details?id=…`, then `https://play.google.com/store/apps/details?id=…` |
65
+
66
+ `openStoreListing(target, androidUrl?)` opens the listing (e.g. on a
67
+ force-update screen). On iOS without a numeric id it opens nothing and resolves
68
+ `false`.
69
+
70
+ ## Development
71
+
72
+ ```bash
73
+ npm test # jest (jest-expo); tests live next to the source, excluded from the tarball
74
+ npm run sync # rsync src/ into Kaelo's node_modules for a quick dev loop (not a release)
75
+ npm pack # build the tarball; install it into an app with `npm i --no-save <tgz>` to verify
76
+ ```
77
+
78
+ Release: bump `version` and add a `CHANGELOG.md` entry, commit, run
79
+ `npm publish` (public), then bump `@klyftig/mobile-ui` in each app and run
80
+ `npm install`.
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@klyftig/mobile-ui",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "scripts": {
5
+ "test": "jest",
5
6
  "sync": "bash scripts/sync-to-kaelo.sh",
6
7
  "pack-install": "npm pack && mv klyftig-mobile-ui-*.tgz /tmp/klyftig-mobile-ui-local.tgz && cd ../kaelo/mobile && npm install /tmp/klyftig-mobile-ui-local.tgz"
7
8
  },
@@ -9,7 +10,9 @@
9
10
  "main": "src/index.ts",
10
11
  "types": "src/index.ts",
11
12
  "files": [
12
- "src"
13
+ "src",
14
+ "!src/**/*.test.ts",
15
+ "!src/**/*.test.tsx"
13
16
  ],
14
17
  "keywords": [
15
18
  "react-native",
@@ -19,22 +22,43 @@
19
22
  ],
20
23
  "license": "MIT",
21
24
  "peerDependencies": {
25
+ "@expo/vector-icons": "*",
22
26
  "expo": "*",
23
- "react": ">=19.0.0",
24
- "react-native": ">=0.83.0",
27
+ "expo-blur": "*",
25
28
  "expo-glass-effect": "*",
26
29
  "expo-linear-gradient": "*",
27
- "expo-blur": "*",
28
- "@expo/vector-icons": "*",
30
+ "expo-store-review": "*",
31
+ "react": ">=19.0.0",
32
+ "react-native": ">=0.83.0",
33
+ "react-native-safe-area-context": "*",
29
34
  "react-native-svg": "*",
30
- "react-native-safe-area-context": "*"
35
+ "zustand": ">=5.0.0"
31
36
  },
32
37
  "publishConfig": {
33
38
  "access": "public"
34
39
  },
35
40
  "devDependencies": {
41
+ "@testing-library/react-native": "^13.3.3",
42
+ "@types/jest": "^29.5.14",
43
+ "@types/react": "~19.2.0",
36
44
  "expo": "~55.0.11",
45
+ "expo-store-review": "~55.0.14",
46
+ "jest": "^29.7.0",
47
+ "jest-expo": "~55.0.14",
37
48
  "react": "19.2.0",
38
- "react-native": "0.83.4"
49
+ "react-native": "0.83.4",
50
+ "react-test-renderer": "^19.2.0",
51
+ "typescript": "^6.0.3",
52
+ "zustand": "^5.0.15"
53
+ },
54
+ "jest": {
55
+ "preset": "jest-expo",
56
+ "testMatch": [
57
+ "**/*.test.ts",
58
+ "**/*.test.tsx"
59
+ ],
60
+ "transformIgnorePatterns": [
61
+ "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|react-native-svg|zustand)"
62
+ ]
39
63
  }
40
64
  }
@@ -13,12 +13,23 @@ import { colors } from '../theme/colors';
13
13
  export interface WheelPickerProps {
14
14
  values: number[];
15
15
  selected: number;
16
- onChange: (value: number) => void;
16
+ /**
17
+ * `wraps` (only with `loop`): how many times this scroll went past the end
18
+ * of the list — +1 for 59 → 00 going down, -1 for 00 → 59 going up. A
19
+ * caller can carry it, e.g. bump the hour when the minutes wrap.
20
+ */
21
+ onChange: (value: number, info: { wraps: number }) => void;
17
22
  formatLabel?: (value: number) => string;
18
23
  itemHeight?: number;
19
24
  visibleCount?: number;
20
25
  width?: number;
21
26
  flex?: number;
27
+ /**
28
+ * Wrap around (59 → 00 → 01, and back from 00 to 59), like a clock. The
29
+ * values are laid out LOOP_COPIES times and the wheel recentres on the
30
+ * middle copy after every scroll, so it never reaches an end in practice.
31
+ */
32
+ loop?: boolean;
22
33
  }
23
34
 
24
35
  const DEFAULT_ITEM_HEIGHT = 48;
@@ -32,6 +43,9 @@ const DEFAULT_VISIBLE_COUNT = 3;
32
43
  // and momentum behave normally, but we don't mount 600 <Text> nodes on first
33
44
  // paint of MeasurementsStep.
34
45
  const WINDOW_OVERSCAN = 8;
46
+ // Odd, so there is a middle copy to start in and snap back to. 21 copies of
47
+ // 60 minutes is 1260 rows — only the overscan window is mounted.
48
+ const LOOP_COPIES = 21;
35
49
 
36
50
  export function WheelPicker({
37
51
  values,
@@ -42,6 +56,7 @@ export function WheelPicker({
42
56
  visibleCount = DEFAULT_VISIBLE_COUNT,
43
57
  width,
44
58
  flex,
59
+ loop = false,
45
60
  }: WheelPickerProps) {
46
61
  const { colors } = useTheme();
47
62
  const scrollRef = useRef<ScrollView>(null);
@@ -49,10 +64,18 @@ export function WheelPicker({
49
64
  const padding = (containerHeight - itemHeight) / 2;
50
65
  const lastIndexRef = useRef<number>(-1);
51
66
 
52
- const selectedIndex = useMemo(() => {
67
+ // Rows are virtual indices; with `loop` they span LOOP_COPIES copies of
68
+ // `values` and row i shows values[i mod n].
69
+ const n = values.length;
70
+ const rowCount = loop ? n * LOOP_COPIES : n;
71
+ const middleStart = loop ? Math.floor(LOOP_COPIES / 2) * n : 0;
72
+ const valueIndexOf = useCallback((row: number) => ((row % n) + n) % n, [n]);
73
+
74
+ const valueIndex = useMemo(() => {
53
75
  const idx = values.indexOf(selected);
54
76
  return idx >= 0 ? idx : 0;
55
77
  }, [values, selected]);
78
+ const selectedIndex = middleStart + valueIndex;
56
79
 
57
80
  // Track the row currently centered under the selection overlay during scroll
58
81
  // so the rendered window follows the user's drag, not just the committed
@@ -73,38 +96,49 @@ export function WheelPicker({
73
96
  (e: NativeSyntheticEvent<NativeScrollEvent>) => {
74
97
  const offsetY = e.nativeEvent.contentOffset.y;
75
98
  const index = Math.round(offsetY / itemHeight);
76
- const clamped = Math.max(0, Math.min(values.length - 1, index));
99
+ const clamped = Math.max(0, Math.min(rowCount - 1, index));
77
100
  setWindowCenter((prev) => (Math.abs(prev - clamped) >= 2 ? clamped : prev));
78
101
  },
79
- [itemHeight, values.length],
102
+ [itemHeight, rowCount],
80
103
  );
81
104
 
82
105
  const handleMomentumEnd = useCallback(
83
106
  (e: NativeSyntheticEvent<NativeScrollEvent>) => {
84
107
  const offsetY = e.nativeEvent.contentOffset.y;
85
108
  const index = Math.round(offsetY / itemHeight);
86
- const clamped = Math.max(0, Math.min(values.length - 1, index));
87
- setWindowCenter(clamped);
88
- if (values[clamped] !== selected) {
89
- onChange(values[clamped]);
109
+ const clamped = Math.max(0, Math.min(rowCount - 1, index));
110
+ const value = values[valueIndexOf(clamped)];
111
+ // Every scroll starts in the middle copy, so copies crossed = wraps.
112
+ const wraps = loop ? Math.floor(clamped / n) - Math.floor(LOOP_COPIES / 2) : 0;
113
+ let row = clamped;
114
+ if (loop) {
115
+ // Jump back to the same value in the middle copy (invisible: the rows
116
+ // look identical) so the next fling has room either way.
117
+ row = middleStart + valueIndexOf(clamped);
118
+ if (row !== clamped) scrollRef.current?.scrollTo({ y: row * itemHeight, animated: false });
119
+ lastIndexRef.current = row;
120
+ }
121
+ setWindowCenter(row);
122
+ if (value !== selected || wraps !== 0) {
123
+ onChange(value, { wraps });
90
124
  }
91
125
  },
92
- [itemHeight, values, selected, onChange],
126
+ [itemHeight, n, rowCount, values, valueIndexOf, loop, middleStart, selected, onChange],
93
127
  );
94
128
 
95
129
  const windowStart = Math.max(0, windowCenter - WINDOW_OVERSCAN);
96
- const windowEnd = Math.min(values.length - 1, windowCenter + WINDOW_OVERSCAN);
130
+ const windowEnd = Math.min(rowCount - 1, windowCenter + WINDOW_OVERSCAN);
97
131
 
98
132
  const dimColor = colors.text.tertiary;
99
133
  const activeColor = colors.text.primary;
100
134
 
101
135
  const items = [];
102
136
  for (let i = windowStart; i <= windowEnd; i++) {
103
- const value = values[i];
137
+ const value = values[valueIndexOf(i)];
104
138
  const isSelected = i === selectedIndex;
105
139
  items.push(
106
140
  <View
107
- key={value}
141
+ key={i}
108
142
  style={[
109
143
  styles.item,
110
144
  {
@@ -128,7 +162,7 @@ export function WheelPicker({
128
162
  );
129
163
  }
130
164
 
131
- const totalContentHeight = values.length * itemHeight + padding * 2;
165
+ const totalContentHeight = rowCount * itemHeight + padding * 2;
132
166
 
133
167
  return (
134
168
  <View style={[styles.wrapper, { height: containerHeight, width, flex }]}>
package/src/index.ts CHANGED
@@ -56,3 +56,6 @@ export { CircularProgress } from './components/progress/CircularProgress';
56
56
  export type { CircularProgressMode } from './components/progress/CircularProgress';
57
57
  export { CArcProgress } from './components/progress/CArcProgress';
58
58
  export { MacroBar } from './components/progress/MacroBar';
59
+
60
+ // Review (Rate Us)
61
+ export * from './review';
@@ -0,0 +1,143 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { View, Text, StyleSheet, TouchableOpacity, Linking } from 'react-native';
3
+ import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
4
+ import { CenterDialog } from '../components/CenterDialog';
5
+ import { colors } from '../theme/colors';
6
+ import type { ReviewPrompt } from './createReviewPrompt';
7
+ import { requestStoreReview } from './storeLinks';
8
+ import { defaultReviewStrings, type RateAppStrings } from './strings';
9
+
10
+ export interface RateAppModalProps {
11
+ review: ReviewPrompt;
12
+ /** Overrides for the default English strings (see `reviewStringsFromT`). */
13
+ strings?: Partial<RateAppStrings>;
14
+ }
15
+
16
+ const STARS = [1, 2, 3, 4, 5];
17
+ const STAR_ON = '#F5A623';
18
+ const STAR_OFF = '#C5C5C5';
19
+
20
+ /** Lowest rating sent to the store; anything below goes to private feedback. */
21
+ export const POSITIVE_RATING_THRESHOLD = 4;
22
+
23
+ /**
24
+ * Sentiment gate: 4–5★ → store review (`requestStoreReview`), 1–3★ → feedback
25
+ * email to the configured support address. Either way the user counts as rated.
26
+ * Mount once, globally; it shows itself when the review store is visible.
27
+ */
28
+ export const RateAppModal: React.FC<RateAppModalProps> = ({ review, strings }) => {
29
+ const visible = review.useStore((s) => s.visible);
30
+ const reason = review.useStore((s) => s.reason);
31
+ const [selected, setSelected] = useState(0);
32
+ const s: RateAppStrings = { ...defaultReviewStrings(review.config.appName), ...strings };
33
+ const thankYou = typeof s.thankYou === 'function' ? s.thankYou(reason) : s.thankYou;
34
+
35
+ // A fresh prompt never shows the stars from a previous one.
36
+ useEffect(() => {
37
+ if (visible) setSelected(0);
38
+ }, [visible]);
39
+
40
+ const handleSelect = (rating: number) => {
41
+ review.config.haptics?.selection?.();
42
+ setSelected(rating);
43
+ if (rating >= POSITIVE_RATING_THRESHOLD) {
44
+ void requestStoreReview(review.config);
45
+ } else {
46
+ const subject = encodeURIComponent(s.feedbackSubject);
47
+ Linking.openURL(`mailto:${review.config.supportEmail}?subject=${subject}`).catch(() => {});
48
+ }
49
+ review.markRated();
50
+ };
51
+
52
+ return (
53
+ <CenterDialog
54
+ visible={visible}
55
+ onDismiss={review.dismiss}
56
+ backdropAccessibilityLabel={s.close}
57
+ contentStyle={styles.content}
58
+ >
59
+ <TouchableOpacity
60
+ style={styles.closeButton}
61
+ onPress={review.dismiss}
62
+ hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
63
+ accessibilityRole="button"
64
+ accessibilityLabel={s.close}
65
+ >
66
+ <Icon name="close" size={22} color={colors.text.tertiary} />
67
+ </TouchableOpacity>
68
+
69
+ <Text style={styles.thankYou}>{thankYou}</Text>
70
+ <Text style={styles.title}>{s.title}</Text>
71
+ <Text style={styles.subtitle}>{s.subtitle}</Text>
72
+
73
+ <View style={styles.stars}>
74
+ {STARS.map((rating) => (
75
+ <TouchableOpacity
76
+ key={rating}
77
+ onPress={() => handleSelect(rating)}
78
+ hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
79
+ accessibilityRole="button"
80
+ accessibilityLabel={s.star(rating)}
81
+ >
82
+ <Icon
83
+ name={rating <= selected ? 'star' : 'star-outline'}
84
+ size={40}
85
+ color={rating <= selected ? STAR_ON : STAR_OFF}
86
+ />
87
+ </TouchableOpacity>
88
+ ))}
89
+ </View>
90
+
91
+ <TouchableOpacity style={styles.later} onPress={review.dismiss} accessibilityRole="button">
92
+ <Text style={styles.laterText}>{s.later}</Text>
93
+ </TouchableOpacity>
94
+ </CenterDialog>
95
+ );
96
+ };
97
+
98
+ const styles = StyleSheet.create({
99
+ content: {
100
+ alignItems: 'center',
101
+ },
102
+ closeButton: {
103
+ position: 'absolute',
104
+ top: 14,
105
+ right: 14,
106
+ },
107
+ thankYou: {
108
+ fontSize: 13,
109
+ fontWeight: '600',
110
+ color: colors.accent.warning,
111
+ textAlign: 'center',
112
+ marginTop: 8,
113
+ },
114
+ title: {
115
+ fontSize: 19,
116
+ fontWeight: '700',
117
+ color: colors.text.primary,
118
+ textAlign: 'center',
119
+ marginTop: 4,
120
+ },
121
+ subtitle: {
122
+ fontSize: 14,
123
+ color: colors.text.secondary,
124
+ textAlign: 'center',
125
+ marginTop: 8,
126
+ },
127
+ stars: {
128
+ flexDirection: 'row',
129
+ gap: 6,
130
+ marginTop: 20,
131
+ marginBottom: 8,
132
+ },
133
+ later: {
134
+ marginTop: 12,
135
+ paddingVertical: 8,
136
+ paddingHorizontal: 16,
137
+ },
138
+ laterText: {
139
+ fontSize: 14,
140
+ fontWeight: '600',
141
+ color: colors.text.tertiary,
142
+ },
143
+ });
@@ -0,0 +1,221 @@
1
+ import { create, type StoreApi, type UseBoundStore } from 'zustand';
2
+ import type { StoreTarget } from './storeLinks';
3
+
4
+ /** AsyncStorage-compatible key/value storage (pass `AsyncStorage` itself). */
5
+ export interface ReviewStorage {
6
+ getItem(key: string): Promise<string | null>;
7
+ setItem(key: string, value: string): Promise<void>;
8
+ }
9
+
10
+ export interface ReviewPromptConfig extends StoreTarget {
11
+ /** Shown in the default English strings ("How do you like Kaelo?"). */
12
+ appName: string;
13
+ /** 1–3★ feedback goes to `mailto:<supportEmail>`. */
14
+ supportEmail: string;
15
+ /** Persistence key, e.g. `review.prompt.v1`. Keep it stable once shipped. */
16
+ storageKey: string;
17
+ storage: ReviewStorage;
18
+ /** Event counts that surface the prompt until {@link ReviewPrompt.configure} overrides them. Default {@link DEFAULT_REVIEW_MILESTONES}. */
19
+ defaultMilestones?: number[];
20
+ /** Fired on every star tap. */
21
+ haptics?: { selection?: () => void };
22
+ }
23
+
24
+ /** Shown at the 3rd happy-moment event; if dismissed, once more at the 6th; then never. */
25
+ export const DEFAULT_REVIEW_MILESTONES: readonly number[] = [3, 6];
26
+
27
+ /** Reason recorded by {@link ReviewPrompt.recordEvent} when the caller gives none. */
28
+ export const DEFAULT_REVIEW_REASON = 'milestone';
29
+
30
+ export interface PersistedReviewState {
31
+ /** Cumulative happy-moment events. */
32
+ logCount: number;
33
+ /** Highest milestone already surfaced. */
34
+ lastMilestoneShown: number;
35
+ /** Rated (any sentiment) → never prompt again. */
36
+ rated: boolean;
37
+ /** One-time triggers ({@link ReviewPrompt.recordOneTimeTrigger}) already surfaced. */
38
+ triggersShown: string[];
39
+ }
40
+
41
+ export interface ReviewPromptState extends PersistedReviewState {
42
+ /** Modal is on screen. */
43
+ visible: boolean;
44
+ /** A trigger fired; waiting for {@link ReviewPrompt.showIfPending} at a safe moment. */
45
+ pending: boolean;
46
+ /** What surfaced the current prompt — lets the modal pick its thank-you line. */
47
+ reason: string;
48
+ /** Remote kill-switch. Not persisted. */
49
+ enabled: boolean;
50
+ /** Active milestones. Not persisted. */
51
+ milestones: number[];
52
+ }
53
+
54
+ export interface RemoteReviewConfig {
55
+ enabled?: unknown;
56
+ milestones?: unknown;
57
+ }
58
+
59
+ export interface ReviewPrompt {
60
+ readonly config: ReviewPromptConfig;
61
+ /** Zustand hook + `getState`/`setState`/`subscribe`. */
62
+ readonly useStore: UseBoundStore<StoreApi<ReviewPromptState>>;
63
+ /** Load persisted counters. Idempotent: every caller awaits the same load. */
64
+ hydrate(): Promise<void>;
65
+ /** Apply remote overrides (fail-open: missing/invalid fields keep the current value). */
66
+ configure(remote: RemoteReviewConfig): void;
67
+ /** Count one happy-moment event; at a not-yet-shown milestone the prompt goes pending. */
68
+ recordEvent(reason?: string): Promise<void>;
69
+ /** Mark the prompt pending for `triggerId`, once ever per trigger (e.g. "2nd habit created"). */
70
+ recordOneTimeTrigger(triggerId: string, reason?: string): Promise<void>;
71
+ /** Promote a pending prompt to visible. Call from a screen where a modal is safe. */
72
+ showIfPending(): void;
73
+ /** User finished the flow (any rating). Never prompt again. */
74
+ markRated(): void;
75
+ /** User closed without rating. Eligible again at the next milestone only. */
76
+ dismiss(): void;
77
+ /** Test-only: back to defaults and forget hydration. */
78
+ resetForTests(): void;
79
+ }
80
+
81
+ /** Positive, ascending, unique integers; null when nothing valid remains. */
82
+ export function sanitizeMilestones(input: unknown): number[] | null {
83
+ if (!Array.isArray(input)) return null;
84
+ const clean = [
85
+ ...new Set(
86
+ input
87
+ .filter((m): m is number => typeof m === 'number' && Number.isFinite(m) && m >= 1)
88
+ .map((m) => Math.floor(m))
89
+ ),
90
+ ].sort((a, b) => a - b);
91
+ return clean.length > 0 ? clean : null;
92
+ }
93
+
94
+ const PERSISTED_DEFAULTS: PersistedReviewState = {
95
+ logCount: 0,
96
+ lastMilestoneShown: 0,
97
+ rated: false,
98
+ triggersShown: [],
99
+ };
100
+
101
+ function parsePersisted(raw: string): PersistedReviewState {
102
+ const parsed: unknown = JSON.parse(raw);
103
+ if (typeof parsed !== 'object' || parsed === null) return PERSISTED_DEFAULTS;
104
+ const p = parsed as Record<string, unknown>;
105
+ const count = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0);
106
+ return {
107
+ logCount: count(p.logCount),
108
+ lastMilestoneShown: count(p.lastMilestoneShown),
109
+ rated: p.rated === true,
110
+ triggersShown: Array.isArray(p.triggersShown)
111
+ ? p.triggersShown.filter((t): t is string => typeof t === 'string')
112
+ : [],
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Create an app's review-prompt store. Call once per app (module scope) and
118
+ * share the returned object; render `<RateAppModal review={…} />` with it.
119
+ */
120
+ export function createReviewPrompt(config: ReviewPromptConfig): ReviewPrompt {
121
+ const baseMilestones = sanitizeMilestones(config.defaultMilestones) ?? [...DEFAULT_REVIEW_MILESTONES];
122
+ const initialState = (): ReviewPromptState => ({
123
+ ...PERSISTED_DEFAULTS,
124
+ triggersShown: [],
125
+ visible: false,
126
+ pending: false,
127
+ reason: DEFAULT_REVIEW_REASON,
128
+ enabled: true,
129
+ milestones: baseMilestones,
130
+ });
131
+
132
+ const useStore = create<ReviewPromptState>(initialState);
133
+ let hydration: Promise<void> | null = null;
134
+
135
+ async function load(): Promise<void> {
136
+ try {
137
+ const raw = await config.storage.getItem(config.storageKey);
138
+ if (raw) useStore.setState(parsePersisted(raw));
139
+ } catch {
140
+ // Unreadable or corrupt — keep defaults.
141
+ }
142
+ }
143
+
144
+ function persist(): void {
145
+ const { logCount, lastMilestoneShown, rated, triggersShown } = useStore.getState();
146
+ const snapshot: PersistedReviewState = { logCount, lastMilestoneShown, rated, triggersShown };
147
+ try {
148
+ config.storage.setItem(config.storageKey, JSON.stringify(snapshot)).catch(() => {});
149
+ } catch {
150
+ // Synchronous storage failure — the counters live on in memory.
151
+ }
152
+ }
153
+
154
+ const hydrate = (): Promise<void> => {
155
+ if (!hydration) hydration = load();
156
+ return hydration;
157
+ };
158
+
159
+ return {
160
+ config,
161
+ useStore,
162
+ hydrate,
163
+
164
+ configure(remote) {
165
+ const next: Partial<ReviewPromptState> = {};
166
+ if (typeof remote.enabled === 'boolean') next.enabled = remote.enabled;
167
+ const milestones = sanitizeMilestones(remote.milestones);
168
+ if (milestones) next.milestones = milestones;
169
+ if (Object.keys(next).length > 0) useStore.setState(next);
170
+ },
171
+
172
+ // Hydrates first: a cold launch can record an event before anything else
173
+ // loaded the counters, and persisting in-memory defaults then would erase
174
+ // a stored "rated".
175
+ async recordEvent(reason = DEFAULT_REVIEW_REASON) {
176
+ await hydrate();
177
+ const state = useStore.getState();
178
+ const logCount = state.logCount + 1;
179
+ const next: Partial<ReviewPromptState> = { logCount };
180
+ if (state.enabled && !state.rated) {
181
+ const reached = state.milestones.filter((m) => logCount >= m && m > state.lastMilestoneShown);
182
+ if (reached.length > 0) {
183
+ next.lastMilestoneShown = Math.max(...reached);
184
+ next.pending = true;
185
+ next.reason = reason;
186
+ }
187
+ }
188
+ useStore.setState(next);
189
+ persist();
190
+ },
191
+
192
+ async recordOneTimeTrigger(triggerId, reason = triggerId) {
193
+ await hydrate();
194
+ const state = useStore.getState();
195
+ if (!state.enabled || state.rated || state.triggersShown.includes(triggerId)) return;
196
+ useStore.setState({ triggersShown: [...state.triggersShown, triggerId], pending: true, reason });
197
+ persist();
198
+ },
199
+
200
+ showIfPending() {
201
+ const { pending, visible, rated, enabled } = useStore.getState();
202
+ if (pending && !visible && !rated && enabled) {
203
+ useStore.setState({ visible: true, pending: false });
204
+ }
205
+ },
206
+
207
+ markRated() {
208
+ useStore.setState({ rated: true, visible: false, pending: false });
209
+ persist();
210
+ },
211
+
212
+ dismiss() {
213
+ useStore.setState({ visible: false, pending: false });
214
+ },
215
+
216
+ resetForTests() {
217
+ hydration = null;
218
+ useStore.setState(initialState(), true);
219
+ },
220
+ };
221
+ }
@@ -0,0 +1,25 @@
1
+ export {
2
+ createReviewPrompt,
3
+ sanitizeMilestones,
4
+ DEFAULT_REVIEW_MILESTONES,
5
+ DEFAULT_REVIEW_REASON,
6
+ } from './createReviewPrompt';
7
+ export type {
8
+ ReviewPrompt,
9
+ ReviewPromptConfig,
10
+ ReviewPromptState,
11
+ PersistedReviewState,
12
+ RemoteReviewConfig,
13
+ ReviewStorage,
14
+ } from './createReviewPrompt';
15
+ export {
16
+ isValidIosAppId,
17
+ getStoreUrls,
18
+ requestStoreReview,
19
+ openStoreListing,
20
+ } from './storeLinks';
21
+ export type { StoreTarget, StoreUrls, StoreReviewOutcome } from './storeLinks';
22
+ export { RateAppModal, POSITIVE_RATING_THRESHOLD } from './RateAppModal';
23
+ export type { RateAppModalProps } from './RateAppModal';
24
+ export { defaultReviewStrings, reviewStringsFromT } from './strings';
25
+ export type { RateAppStrings, ReviewTranslate } from './strings';
@@ -0,0 +1,122 @@
1
+ import { Linking, Platform } from 'react-native';
2
+ import * as StoreReview from 'expo-store-review';
3
+
4
+ /** Where an app lives in the stores. One per app, defined once in its review config. */
5
+ export interface StoreTarget {
6
+ /**
7
+ * Numeric App Store id (App Store Connect → App Information → Apple ID).
8
+ * Anything non-numeric — unset, empty, or a `SET_…` placeholder — is treated
9
+ * as "no id": no App Store URL is ever built from it.
10
+ */
11
+ iosAppId?: string | null;
12
+ /** Android applicationId, e.g. `com.klyftig.kaelo`. */
13
+ androidPackage: string;
14
+ }
15
+
16
+ export interface StoreUrls {
17
+ /** `itms-apps://` listing, or null when no valid iOS id is configured. */
18
+ iosListing: string | null;
19
+ /** `itms-apps://…?action=write-review`, or null when no valid iOS id is configured. */
20
+ iosWriteReview: string | null;
21
+ /** `market://` deep link into the Play Store app. */
22
+ androidMarket: string;
23
+ /** https Play listing — works without the Play Store app installed. */
24
+ androidWeb: string;
25
+ }
26
+
27
+ /** How a {@link requestStoreReview} call was satisfied. */
28
+ export type StoreReviewOutcome =
29
+ /** iOS: opened the App Store's write-review sheet via the numeric id. */
30
+ | 'ios-write-review'
31
+ /** `expo-store-review` `requestReview()` (SKStoreReviewController / Play In-App Review). */
32
+ | 'native-prompt'
33
+ /** Android: opened the Play Store app via `market://`. */
34
+ | 'android-market'
35
+ /** Android: opened the https Play listing. */
36
+ | 'android-web'
37
+ /** Nothing could be opened (simulator, no store, every fallback failed). */
38
+ | 'unavailable';
39
+
40
+ const IOS_APP_ID_PATTERN = /^[0-9]+$/;
41
+
42
+ /** True only for a purely numeric App Store id; placeholders such as `SET_NAGGER_IOS_APP_ID` are rejected. */
43
+ export function isValidIosAppId(id: string | null | undefined): id is string {
44
+ return typeof id === 'string' && IOS_APP_ID_PATTERN.test(id);
45
+ }
46
+
47
+ // itms-apps:// opens the App Store app directly. https://apps.apple.com/ is a
48
+ // universal link that React Native's Linking rejects, because the calling app
49
+ // is not associated with that domain.
50
+ export function getStoreUrls(target: StoreTarget): StoreUrls {
51
+ const iosId = isValidIosAppId(target.iosAppId) ? target.iosAppId : null;
52
+ return {
53
+ iosListing: iosId ? `itms-apps://apps.apple.com/app/id${iosId}` : null,
54
+ iosWriteReview: iosId ? `itms-apps://apps.apple.com/app/id${iosId}?action=write-review` : null,
55
+ androidMarket: `market://details?id=${target.androidPackage}`,
56
+ androidWeb: `https://play.google.com/store/apps/details?id=${target.androidPackage}`,
57
+ };
58
+ }
59
+
60
+ async function tryOpen(url: string): Promise<boolean> {
61
+ try {
62
+ await Linking.openURL(url);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ async function tryNativePrompt(): Promise<boolean> {
70
+ try {
71
+ if (!(await StoreReview.isAvailableAsync())) return false;
72
+ await StoreReview.requestReview();
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ async function openAndroidStore(urls: StoreUrls): Promise<StoreReviewOutcome> {
80
+ // market:// fails on devices without the Play Store app; https always has a handler.
81
+ if (await tryOpen(urls.androidMarket)) return 'android-market';
82
+ if (await tryOpen(urls.androidWeb)) return 'android-web';
83
+ return 'unavailable';
84
+ }
85
+
86
+ /**
87
+ * Ask the user for a store review — the positive branch of the sentiment gate.
88
+ *
89
+ * - iOS with a numeric id → App Store write-review deep link (native prompt if
90
+ * the URL cannot be opened).
91
+ * - iOS without one → `requestReview()` after `isAvailableAsync()`. A URL is
92
+ * never built from a placeholder id.
93
+ * - Android → Play In-App Review via `requestReview()`, falling back to
94
+ * `market://`, then the https listing.
95
+ *
96
+ * Never throws.
97
+ */
98
+ export async function requestStoreReview(target: StoreTarget): Promise<StoreReviewOutcome> {
99
+ const urls = getStoreUrls(target);
100
+ if (Platform.OS === 'ios') {
101
+ if (urls.iosWriteReview && (await tryOpen(urls.iosWriteReview))) return 'ios-write-review';
102
+ return (await tryNativePrompt()) ? 'native-prompt' : 'unavailable';
103
+ }
104
+ if (await tryNativePrompt()) return 'native-prompt';
105
+ return openAndroidStore(urls);
106
+ }
107
+
108
+ /**
109
+ * Open the app's store listing (e.g. a force-update screen).
110
+ *
111
+ * iOS needs a numeric id — without one this resolves false and opens nothing.
112
+ * Android opens `androidUrl` when given (e.g. a backend-supplied listing),
113
+ * else `market://` with the https listing as fallback. Never throws.
114
+ */
115
+ export async function openStoreListing(target: StoreTarget, androidUrl?: string | null): Promise<boolean> {
116
+ const urls = getStoreUrls(target);
117
+ if (Platform.OS === 'ios') {
118
+ return urls.iosListing ? tryOpen(urls.iosListing) : false;
119
+ }
120
+ if (androidUrl && (await tryOpen(androidUrl))) return true;
121
+ return (await openAndroidStore(urls)) !== 'unavailable';
122
+ }
@@ -0,0 +1,47 @@
1
+ /** Every user-visible string in {@link RateAppModal}. */
2
+ export interface RateAppStrings {
3
+ /** Small line above the title; a function picks a line per prompt reason. */
4
+ thankYou: string | ((reason: string) => string);
5
+ title: string;
6
+ subtitle: string;
7
+ later: string;
8
+ /** Accessibility label of the close button and the backdrop. */
9
+ close: string;
10
+ /** Accessibility label of each star. */
11
+ star: (stars: number) => string;
12
+ /** Subject of the 1–3★ feedback email. */
13
+ feedbackSubject: string;
14
+ }
15
+
16
+ /** English fallbacks for apps without translations. */
17
+ export function defaultReviewStrings(appName: string): RateAppStrings {
18
+ return {
19
+ thankYou: `Thank you for using ${appName}!`,
20
+ title: `How do you like ${appName}?`,
21
+ subtitle: 'Tap a star to rate your experience.',
22
+ later: 'Maybe later',
23
+ close: 'Close',
24
+ star: (stars) => `Rate ${stars} stars`,
25
+ feedbackSubject: `${appName} feedback`,
26
+ };
27
+ }
28
+
29
+ /** Minimal translate signature — an i18next `t` bound to the review namespace fits. */
30
+ export type ReviewTranslate = (key: string, options?: { stars: number }) => string;
31
+
32
+ /**
33
+ * Build strings from a translate function using the shared Klyftig `review`
34
+ * namespace keys: `thankYou`, `title`, `subtitle`, `later`, `a11yClose`,
35
+ * `a11yStar` (`{{stars}}`), `feedbackSubject`.
36
+ */
37
+ export function reviewStringsFromT(t: ReviewTranslate): RateAppStrings {
38
+ return {
39
+ thankYou: t('thankYou'),
40
+ title: t('title'),
41
+ subtitle: t('subtitle'),
42
+ later: t('later'),
43
+ close: t('a11yClose'),
44
+ star: (stars) => t('a11yStar', { stars }),
45
+ feedbackSubject: t('feedbackSubject'),
46
+ };
47
+ }