Skip to content

Commit 42d6887

Browse files
committed
feat(mobileapp): add percentage slider widgets for yield deposit/withdrawal
Closes #572 - Replace quick-fill percentage buttons with interactive PercentageSlider component - Slider snaps to 25%, 50%, 75%, 100% with draggable thumb via PanResponder - Tap-to-select labels for each snap point below the track - Numeric keyboard (NairaKeypad) remains for manual amount entry - Slider resets to 0 when user types manually or switches deposit/withdraw mode - Re-uses Outfit font family and COLORS constants to maintain transfer flow style consistency
1 parent af6e582 commit 42d6887

1 file changed

Lines changed: 221 additions & 15 deletions

File tree

mobileapp/app/yield-transaction.tsx

Lines changed: 221 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState } from "react";
1+
import React, { useState, useRef, useCallback } from "react";
22
import {
33
View,
44
Text,
@@ -8,6 +8,9 @@ import {
88
Modal,
99
KeyboardAvoidingView,
1010
Platform,
11+
PanResponder,
12+
GestureResponderEvent,
13+
PanResponderGestureState,
1114
} from "react-native";
1215
import { SafeAreaView } from "react-native-safe-area-context";
1316
import { Ionicons } from "@expo/vector-icons";
@@ -33,6 +36,206 @@ const NAIRA_KEYPAD_KEYS = [
3336
"⌫",
3437
];
3538

39+
// ── Percentage Slider ─────────────────────────────────────────────────────────
40+
41+
const SNAP_PERCENTAGES = [0, 25, 50, 75, 100];
42+
43+
interface PercentageSliderProps {
44+
/** Current selected percentage (0–100). */
45+
value: number;
46+
/** Called with the snapped percentage when the user slides or taps a tick. */
47+
onChange: (pct: number) => void;
48+
}
49+
50+
/**
51+
* Horizontal slider that snaps to 0 / 25 / 50 / 75 / 100 %.
52+
* Dragging the thumb or tapping a tick label both update the selection.
53+
*/
54+
function PercentageSlider({ value, onChange }: PercentageSliderProps) {
55+
const trackWidth = useRef(0);
56+
57+
const snapToNearest = useCallback(
58+
(rawPct: number) => {
59+
const clamped = Math.max(0, Math.min(100, rawPct));
60+
let closest = SNAP_PERCENTAGES[0];
61+
let minDist = Math.abs(clamped - closest);
62+
for (const snap of SNAP_PERCENTAGES) {
63+
const dist = Math.abs(clamped - snap);
64+
if (dist < minDist) {
65+
minDist = dist;
66+
closest = snap;
67+
}
68+
}
69+
onChange(closest);
70+
},
71+
[onChange]
72+
);
73+
74+
const panResponder = useRef(
75+
PanResponder.create({
76+
onStartShouldSetPanResponder: () => true,
77+
onMoveShouldSetPanResponder: () => true,
78+
onPanResponderGrant: (
79+
evt: GestureResponderEvent,
80+
_state: PanResponderGestureState
81+
) => {
82+
if (trackWidth.current === 0) return;
83+
const x = evt.nativeEvent.locationX;
84+
const rawPct = (x / trackWidth.current) * 100;
85+
snapToNearest(rawPct);
86+
},
87+
onPanResponderMove: (
88+
evt: GestureResponderEvent,
89+
_state: PanResponderGestureState
90+
) => {
91+
if (trackWidth.current === 0) return;
92+
const x = evt.nativeEvent.locationX;
93+
const rawPct = (x / trackWidth.current) * 100;
94+
snapToNearest(rawPct);
95+
},
96+
})
97+
).current;
98+
99+
const thumbPosition = `${value}%` as `${number}%`;
100+
101+
return (
102+
<View style={sliderStyles.wrapper}>
103+
{/* Track */}
104+
<View
105+
style={sliderStyles.trackContainer}
106+
onLayout={(e) => {
107+
trackWidth.current = e.nativeEvent.layout.width;
108+
}}
109+
{...panResponder.panHandlers}
110+
accessible={false}
111+
>
112+
{/* Filled portion */}
113+
<View style={[sliderStyles.trackFill, { width: thumbPosition }]} />
114+
{/* Tick marks */}
115+
{SNAP_PERCENTAGES.map((pct) => (
116+
<View
117+
key={pct}
118+
style={[
119+
sliderStyles.tick,
120+
{ left: `${pct}%` as `${number}%` },
121+
pct === value && sliderStyles.tickActive,
122+
]}
123+
/>
124+
))}
125+
{/* Thumb */}
126+
<View
127+
style={[sliderStyles.thumb, { left: thumbPosition }]}
128+
accessibilityRole="adjustable"
129+
accessibilityValue={{ min: 0, max: 100, now: value }}
130+
accessibilityLabel={`Percentage slider, ${value}%`}
131+
/>
132+
</View>
133+
134+
{/* Snap-point labels */}
135+
<View style={sliderStyles.labelsRow}>
136+
{SNAP_PERCENTAGES.filter((p) => p > 0).map((pct) => (
137+
<TouchableOpacity
138+
key={pct}
139+
style={[
140+
sliderStyles.labelBtn,
141+
pct === value && sliderStyles.labelBtnActive,
142+
]}
143+
onPress={() => onChange(pct)}
144+
activeOpacity={0.75}
145+
accessibilityRole="button"
146+
accessibilityLabel={`Set ${pct} percent`}
147+
>
148+
<Text
149+
style={[
150+
sliderStyles.labelText,
151+
pct === value && sliderStyles.labelTextActive,
152+
]}
153+
>
154+
{pct}%
155+
</Text>
156+
</TouchableOpacity>
157+
))}
158+
</View>
159+
</View>
160+
);
161+
}
162+
163+
const sliderStyles = StyleSheet.create({
164+
wrapper: {
165+
marginBottom: 20,
166+
},
167+
trackContainer: {
168+
height: 28,
169+
backgroundColor: "#E8E8E8",
170+
borderRadius: 14,
171+
marginHorizontal: 8,
172+
justifyContent: "center",
173+
overflow: "visible",
174+
position: "relative",
175+
},
176+
trackFill: {
177+
position: "absolute",
178+
left: 0,
179+
top: 0,
180+
bottom: 0,
181+
backgroundColor: COLORS.primary,
182+
borderRadius: 14,
183+
},
184+
tick: {
185+
position: "absolute",
186+
width: 2,
187+
height: 10,
188+
backgroundColor: "#CCCCCC",
189+
borderRadius: 1,
190+
top: 9,
191+
marginLeft: -1,
192+
},
193+
tickActive: {
194+
backgroundColor: COLORS.secondary,
195+
},
196+
thumb: {
197+
position: "absolute",
198+
width: 28,
199+
height: 28,
200+
borderRadius: 14,
201+
backgroundColor: COLORS.white,
202+
borderWidth: 2.5,
203+
borderColor: COLORS.primary,
204+
shadowColor: "#000",
205+
shadowOffset: { width: 0, height: 2 },
206+
shadowOpacity: 0.12,
207+
shadowRadius: 4,
208+
elevation: 3,
209+
marginLeft: -14,
210+
top: 0,
211+
},
212+
labelsRow: {
213+
flexDirection: "row",
214+
justifyContent: "space-around",
215+
marginTop: 10,
216+
marginHorizontal: 8,
217+
},
218+
labelBtn: {
219+
paddingVertical: 6,
220+
paddingHorizontal: 12,
221+
borderRadius: 8,
222+
backgroundColor: "#F0F0F0",
223+
minWidth: 52,
224+
alignItems: "center",
225+
},
226+
labelBtnActive: {
227+
backgroundColor: COLORS.primary,
228+
},
229+
labelText: {
230+
fontSize: 13,
231+
fontFamily: "Outfit_600SemiBold",
232+
color: COLORS.primary,
233+
},
234+
labelTextActive: {
235+
color: COLORS.secondary,
236+
},
237+
});
238+
36239
// ── Naira Keypad ─────────────────────────────────────────────────────────────
37240

38241
function NairaKeypad({ onPress }: { onPress: (key: string) => void }) {
@@ -60,6 +263,7 @@ export default function YieldTransactionScreen() {
60263
const [amount, setAmount] = useState("");
61264
const [showConfirm, setShowConfirm] = useState(false);
62265
const [submitted, setSubmitted] = useState(false);
266+
const [sliderPct, setSliderPct] = useState(0);
63267

64268
// Simulated balances — replace with API/store values in production
65269
const availableBalance = 150_000;
@@ -74,6 +278,8 @@ export default function YieldTransactionScreen() {
74278
const isValid = numericAmount > 0 && !exceedsLimit;
75279

76280
function handleKeyPress(key: string) {
281+
// Manual keypad entry clears the slider selection
282+
setSliderPct(0);
77283
if (key === "⌫") {
78284
setAmount((prev) => prev.slice(0, -1));
79285
return;
@@ -162,6 +368,7 @@ export default function YieldTransactionScreen() {
162368
onPress={() => {
163369
setMode("deposit");
164370
setAmount("");
371+
setSliderPct(0);
165372
}}
166373
>
167374
<Ionicons
@@ -187,6 +394,7 @@ export default function YieldTransactionScreen() {
187394
onPress={() => {
188395
setMode("withdraw");
189396
setAmount("");
397+
setSliderPct(0);
190398
}}
191399
>
192400
<Ionicons
@@ -242,20 +450,18 @@ export default function YieldTransactionScreen() {
242450
</Text>
243451
)}
244452

245-
{/* Quick-fill buttons */}
246-
<View style={styles.quickFillRow}>
247-
{[25, 50, 75, 100].map((pct) => (
248-
<TouchableOpacity
249-
key={pct}
250-
style={styles.quickFillBtn}
251-
onPress={() =>
252-
setAmount(((activeBalance * pct) / 100).toFixed(2))
253-
}
254-
>
255-
<Text style={styles.quickFillText}>{pct}%</Text>
256-
</TouchableOpacity>
257-
))}
258-
</View>
453+
{/* Percentage slider */}
454+
<PercentageSlider
455+
value={sliderPct}
456+
onChange={(pct) => {
457+
setSliderPct(pct);
458+
if (pct === 0) {
459+
setAmount("");
460+
} else {
461+
setAmount(((activeBalance * pct) / 100).toFixed(2));
462+
}
463+
}}
464+
/>
259465

260466
{/* Naira keypad */}
261467
<NairaKeypad onPress={handleKeyPress} />

0 commit comments

Comments
 (0)