index.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. import { Audio } from 'expo-av';
  2. import { Image } from 'expo-image';
  3. import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
  4. import React, { useEffect, useRef, useState } from 'react';
  5. import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
  6. import { useSafeAreaInsets } from 'react-native-safe-area-context';
  7. import LotteryGrid from './components/LotteryGrid';
  8. import LotteryReel from './components/LotteryReel';
  9. import { Images } from '@/constants/images';
  10. import services from '@/services/api';
  11. const { width: SCREEN_WIDTH } = Dimensions.get('window');
  12. // Sound URL from Uniapp config
  13. const SOUND_URL = 'https://cdn.acetoys.cn/kai_xin_ma_te/resource/magic/lottery.mp3';
  14. export default function LotteryScreen() {
  15. const router = useRouter();
  16. const params = useLocalSearchParams();
  17. const insets = useSafeAreaInsets();
  18. const num = Number(params.num) || 1;
  19. const isGrid = num >= 10;
  20. const tradeNo = params.tradeNo as string;
  21. const poolId = params.poolId as string;
  22. const [results, setResults] = useState<any[]>([]);
  23. const [pool, setPool] = useState<any[]>([]);
  24. const [loading, setLoading] = useState(true);
  25. const [sound, setSound] = useState<Audio.Sound>();
  26. const [animationFinished, setAnimationFinished] = useState(false);
  27. // Timer Ref for cleanup
  28. const timerRef = useRef<any>(null);
  29. // Layout calculations
  30. const itemWidth = (num === 1) ? 94 : 72; // Adjusted to 72 to match mask calculation exactly (was 71)
  31. // Uniapp width logic:
  32. // width() { return (screenWidth - (this.num == 1 ? 94 : 72)) / 2 }
  33. const maskWidth = (SCREEN_WIDTH - ((num === 1) ? 94 : 72)) / 2;
  34. const padding = SCREEN_WIDTH > 375 ? 32 : 0;
  35. useEffect(() => {
  36. init();
  37. return () => {
  38. sound?.unloadAsync();
  39. if (timerRef.current) clearTimeout(timerRef.current);
  40. };
  41. }, []);
  42. const init = async () => {
  43. try {
  44. console.log('LotteryScreen Init: num', num, 'tradeNo', tradeNo, 'poolId', poolId);
  45. await Promise.all([loadData() /*, playSound()*/]);
  46. } catch (e) {
  47. console.error('LotteryScreen Init Error', e);
  48. } finally {
  49. setLoading(false);
  50. }
  51. }
  52. const playSound = async () => {
  53. try {
  54. const { sound } = await Audio.Sound.createAsync(
  55. { uri: SOUND_URL }
  56. );
  57. setSound(sound);
  58. await sound.playAsync();
  59. } catch (error) {
  60. console.log('Error playing sound', error);
  61. }
  62. };
  63. const loadData = async () => {
  64. if (tradeNo) {
  65. try {
  66. const res: any = await services.award.getApplyResult(tradeNo);
  67. const list = res?.data?.inventoryList || res?.inventoryList || [];
  68. setResults(list);
  69. // 2. Fetch Pool (Goods) if not passed
  70. if (poolId && !isGrid) {
  71. const detailRes = await services.award.getPoolDetail(poolId);
  72. const goods = detailRes?.luckGoodsList || [];
  73. setPool(goods);
  74. } else if (!isGrid) {
  75. setPool(list);
  76. }
  77. // Auto show result after animation
  78. if (timerRef.current) clearTimeout(timerRef.current);
  79. if (!isGrid) {
  80. // Reel mode uses timer
  81. timerRef.current = setTimeout(() => {
  82. handleFinish(list);
  83. }, 2800);
  84. }
  85. // Grid mode handles finish via callback
  86. } catch (error) {
  87. console.error('Load Data Error', error);
  88. // Safety fallback?
  89. }
  90. }
  91. };
  92. const handleFinish = (finalResults?: any[]) => {
  93. if (timerRef.current) {
  94. clearTimeout(timerRef.current);
  95. timerRef.current = null;
  96. }
  97. if (isGrid) {
  98. // Stop sound and mark finished, stay on page
  99. sound?.stopAsync();
  100. setAnimationFinished(true);
  101. return;
  102. }
  103. // Navigate to result modal/screen
  104. const data = finalResults || results;
  105. // We can navigate to a result page or show a local modal components
  106. // Assuming we have a result route
  107. router.replace({
  108. pathname: '/lottery/result' as any,
  109. params: {
  110. results: JSON.stringify(data),
  111. poolId
  112. }
  113. });
  114. };
  115. const handleSkip = () => {
  116. if (isGrid) {
  117. if (animationFinished) {
  118. // User clicked "Claim Prize", exit
  119. router.back();
  120. } else {
  121. // User clicked "Skip Animation", finish immediately
  122. // Ideally LotteryGrid should expose a "finish" method or we force state
  123. // But for now calling handleFinish stops navigation
  124. handleFinish();
  125. // Note: This doesn't force cards to flip instantly unless we implement that prop in LotteryGrid
  126. }
  127. } else {
  128. sound?.stopAsync();
  129. handleFinish();
  130. }
  131. };
  132. if (loading) return (
  133. <View style={styles.container}>
  134. <Image source={{ uri: Images.mine.kaixinMineBg }} style={styles.bg} contentFit="cover" />
  135. </View>
  136. );
  137. return (
  138. <View style={styles.container}>
  139. <Stack.Screen options={{ headerShown: false }} />
  140. <Image source={{ uri: Images.mine.kaixinMineBg }} style={styles.bg} contentFit="cover" />
  141. <View style={styles.maskPage} />
  142. <View style={[styles.wrapper, { paddingTop: padding }]}>
  143. {isGrid ? (
  144. <LotteryGrid
  145. results={results}
  146. onFinish={() => handleFinish()}
  147. />
  148. ) : (
  149. <View style={styles.reelsContainer}>
  150. {/* Left Column Masks */}
  151. <View style={[styles.maskLeft, { width: maskWidth }]} />
  152. {/* Reels */}
  153. <View style={num === 1 ? styles.height1 : styles.height5}>
  154. {results.map((item, index) => (
  155. <View key={index} style={styles.reelRow}>
  156. <LotteryReel
  157. key={`reel-${index}-${pool.length}-${results.length}`}
  158. pool={pool.length > 0 ? pool : results}
  159. result={item}
  160. width={SCREEN_WIDTH}
  161. itemWidth={itemWidth}
  162. index={index}
  163. delay={index * 30} // Very fast stagger
  164. duration={2000} // Faster spin (was 3000)
  165. />
  166. </View>
  167. ))}
  168. </View>
  169. {/* Right Column Masks */}
  170. <View style={[styles.maskRight, { width: maskWidth }]} />
  171. {/* Middle Frame */}
  172. <Image
  173. source={{ uri: num === 1 ? Images.resource.lottery_middle_s : Images.resource.lottery_middle_l }}
  174. style={[
  175. styles.middleImage,
  176. {
  177. height: num === 1 ? 200 : 540,
  178. top: num === 1 ? -35 : -10,
  179. }
  180. ]}
  181. contentFit="contain"
  182. />
  183. </View>
  184. )}
  185. </View>
  186. <View style={styles.bottom}>
  187. <TouchableOpacity
  188. style={styles.skipBtn}
  189. onPress={handleSkip}
  190. >
  191. <Text style={styles.skipText}>
  192. {isGrid && animationFinished ? '收下奖品' : '跳过动画'}
  193. </Text>
  194. </TouchableOpacity>
  195. </View>
  196. </View>
  197. );
  198. }
  199. const styles = StyleSheet.create({
  200. container: {
  201. flex: 1,
  202. backgroundColor: '#222335',
  203. },
  204. bg: {
  205. position: 'absolute',
  206. width: '100%',
  207. height: '100%',
  208. zIndex: -1,
  209. },
  210. maskPage: {
  211. ...StyleSheet.absoluteFillObject,
  212. backgroundColor: 'rgba(0,0,0,0.4)',
  213. zIndex: 0,
  214. },
  215. wrapper: {
  216. flex: 1,
  217. // justifyContent: 'center',
  218. alignItems: 'center',
  219. marginTop: 100, // Adjust based on visual
  220. },
  221. reelsContainer: {
  222. position: 'relative',
  223. alignItems: 'center',
  224. justifyContent: 'center',
  225. // backgroundColor: 'rgba(255,0,0,0.1)', // Debug
  226. },
  227. reelRow: {
  228. overflow: 'hidden',
  229. width: SCREEN_WIDTH,
  230. alignItems: 'center',
  231. justifyContent: 'center',
  232. marginBottom: 12, // Gap between rows
  233. height: 94, // Height of one row (card height + tiny padding)
  234. },
  235. height1: {
  236. height: 130, // 1 row + gaps
  237. justifyContent: 'center',
  238. },
  239. height5: {
  240. height: 540, // 5 rows * (~100)
  241. justifyContent: 'center',
  242. paddingVertical: 10,
  243. },
  244. maskLeft: {
  245. position: 'absolute',
  246. left: 0,
  247. top: 0,
  248. bottom: 0,
  249. backgroundColor: 'rgba(0,0,0,0.5)',
  250. zIndex: 10,
  251. },
  252. maskRight: {
  253. position: 'absolute',
  254. right: 0,
  255. top: 0,
  256. bottom: 0,
  257. backgroundColor: 'rgba(0,0,0,0.5)',
  258. zIndex: 10,
  259. },
  260. middleImage: {
  261. position: 'absolute',
  262. width: SCREEN_WIDTH,
  263. zIndex: 20,
  264. left: 0,
  265. // The image frame should be centered over the reels
  266. // top is handled dynamically or via flex centering in parent if possible
  267. },
  268. bottom: {
  269. position: 'absolute',
  270. bottom: 50,
  271. width: '100%',
  272. alignItems: 'center',
  273. zIndex: 30,
  274. },
  275. skipBtn: {
  276. backgroundColor: 'rgba(255,255,255,0.2)',
  277. paddingHorizontal: 30,
  278. paddingVertical: 10,
  279. borderRadius: 32,
  280. borderWidth: 1,
  281. borderColor: 'rgba(255,255,255,0.4)',
  282. },
  283. skipText: {
  284. color: '#fff',
  285. fontSize: 14,
  286. },
  287. });