@unif/react-native-camera 2.6.0 → 2.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.
Files changed (259) hide show
  1. package/README.md +14 -0
  2. package/lib/module/camera/Camera.js +96 -27
  3. package/lib/module/camera/Camera.js.map +1 -1
  4. package/lib/module/camera/CaptureFlash.js +38 -0
  5. package/lib/module/camera/CaptureFlash.js.map +1 -0
  6. package/lib/module/camera/CaptureFlash.test.js +19 -0
  7. package/lib/module/camera/CaptureFlash.test.js.map +1 -0
  8. package/lib/module/camera/Container.js +166 -64
  9. package/lib/module/camera/Container.js.map +1 -1
  10. package/lib/module/camera/FocusIndicator.js +99 -23
  11. package/lib/module/camera/FocusIndicator.js.map +1 -1
  12. package/lib/module/camera/FocusIndicator.test.js +27 -0
  13. package/lib/module/camera/FocusIndicator.test.js.map +1 -0
  14. package/lib/module/camera/colors/dark.js +22 -0
  15. package/lib/module/camera/colors/dark.js.map +1 -0
  16. package/lib/module/camera/colors/dark.test.js +10 -0
  17. package/lib/module/camera/colors/dark.test.js.map +1 -0
  18. package/lib/module/camera/footer/ActionRow.js +65 -0
  19. package/lib/module/camera/footer/ActionRow.js.map +1 -0
  20. package/lib/module/camera/footer/ActionRow.test.js +53 -0
  21. package/lib/module/camera/footer/ActionRow.test.js.map +1 -0
  22. package/lib/module/camera/footer/FlipButton.js +31 -0
  23. package/lib/module/camera/footer/FlipButton.js.map +1 -0
  24. package/lib/module/camera/footer/FlipButton.test.js +16 -0
  25. package/lib/module/camera/footer/FlipButton.test.js.map +1 -0
  26. package/lib/module/camera/footer/ModeSwitcherPill.js +89 -0
  27. package/lib/module/camera/footer/ModeSwitcherPill.js.map +1 -0
  28. package/lib/module/camera/footer/ModeSwitcherPill.test.js +43 -0
  29. package/lib/module/camera/footer/ModeSwitcherPill.test.js.map +1 -0
  30. package/lib/module/camera/footer/RecordingTimer.js +66 -0
  31. package/lib/module/camera/footer/RecordingTimer.js.map +1 -0
  32. package/lib/module/camera/footer/RecordingTimer.test.js +20 -0
  33. package/lib/module/camera/footer/RecordingTimer.test.js.map +1 -0
  34. package/lib/module/camera/footer/Shutter.js +70 -0
  35. package/lib/module/camera/footer/Shutter.js.map +1 -0
  36. package/lib/module/camera/footer/Shutter.test.js +42 -0
  37. package/lib/module/camera/footer/Shutter.test.js.map +1 -0
  38. package/lib/module/camera/footer/ThumbnailStack.js +71 -0
  39. package/lib/module/camera/footer/ThumbnailStack.js.map +1 -0
  40. package/lib/module/camera/footer/ThumbnailStack.test.js +30 -0
  41. package/lib/module/camera/footer/ThumbnailStack.test.js.map +1 -0
  42. package/lib/module/camera/footer/ZoomChips.js +57 -0
  43. package/lib/module/camera/footer/ZoomChips.js.map +1 -0
  44. package/lib/module/camera/footer/ZoomChips.test.js +17 -0
  45. package/lib/module/camera/footer/ZoomChips.test.js.map +1 -0
  46. package/lib/module/camera/footer/index.js +7 -1
  47. package/lib/module/camera/footer/index.js.map +1 -1
  48. package/lib/module/camera/icons/VolumeIcon.js +44 -0
  49. package/lib/module/camera/icons/VolumeIcon.js.map +1 -0
  50. package/lib/module/camera/icons/VolumeIcon.test.js +18 -0
  51. package/lib/module/camera/icons/VolumeIcon.test.js.map +1 -0
  52. package/lib/module/camera/index.js +3 -1
  53. package/lib/module/camera/index.js.map +1 -1
  54. package/lib/module/camera/preview/PreviewBottomBar.js +69 -0
  55. package/lib/module/camera/preview/PreviewBottomBar.js.map +1 -0
  56. package/lib/module/camera/preview/PreviewBottomBar.test.js +46 -0
  57. package/lib/module/camera/preview/PreviewBottomBar.test.js.map +1 -0
  58. package/lib/module/camera/preview/PreviewOverlay.js +89 -0
  59. package/lib/module/camera/preview/PreviewOverlay.js.map +1 -0
  60. package/lib/module/camera/preview/PreviewOverlay.test.js +45 -0
  61. package/lib/module/camera/preview/PreviewOverlay.test.js.map +1 -0
  62. package/lib/module/camera/preview/PreviewTopBar.js +96 -0
  63. package/lib/module/camera/preview/PreviewTopBar.js.map +1 -0
  64. package/lib/module/camera/preview/PreviewTopBar.test.js +44 -0
  65. package/lib/module/camera/preview/PreviewTopBar.test.js.map +1 -0
  66. package/lib/module/camera/preview/groupTypes.js +11 -0
  67. package/lib/module/camera/preview/groupTypes.js.map +1 -0
  68. package/lib/module/camera/preview/groupTypes.test.js +25 -0
  69. package/lib/module/camera/preview/groupTypes.test.js.map +1 -0
  70. package/lib/module/camera/preview/index.js +3 -4
  71. package/lib/module/camera/preview/index.js.map +1 -1
  72. package/lib/module/camera/setup/SideRail.js +138 -0
  73. package/lib/module/camera/setup/SideRail.js.map +1 -0
  74. package/lib/module/camera/setup/SideRail.test.js +49 -0
  75. package/lib/module/camera/setup/SideRail.test.js.map +1 -0
  76. package/lib/module/camera/setup/index.js +1 -1
  77. package/lib/module/camera/setup/index.js.map +1 -1
  78. package/lib/module/components/Carousel/SlideItem.js +5 -3
  79. package/lib/module/components/Carousel/SlideItem.js.map +1 -1
  80. package/lib/module/components/Carousel/SlideItem.test.js +39 -0
  81. package/lib/module/components/Carousel/SlideItem.test.js.map +1 -0
  82. package/lib/module/components/VideoPlayer.js +34 -0
  83. package/lib/module/components/VideoPlayer.js.map +1 -0
  84. package/lib/module/components/VideoPlayer.test.js +15 -0
  85. package/lib/module/components/VideoPlayer.test.js.map +1 -0
  86. package/lib/module/components/index.js +0 -1
  87. package/lib/module/components/index.js.map +1 -1
  88. package/lib/module/hooks/index.js +0 -1
  89. package/lib/module/hooks/index.js.map +1 -1
  90. package/lib/typescript/src/camera/Camera.d.ts +3 -0
  91. package/lib/typescript/src/camera/Camera.d.ts.map +1 -1
  92. package/lib/typescript/src/camera/CaptureFlash.d.ts +6 -0
  93. package/lib/typescript/src/camera/CaptureFlash.d.ts.map +1 -0
  94. package/lib/typescript/src/camera/CaptureFlash.test.d.ts +2 -0
  95. package/lib/typescript/src/camera/CaptureFlash.test.d.ts.map +1 -0
  96. package/lib/typescript/src/camera/Container.d.ts.map +1 -1
  97. package/lib/typescript/src/camera/FocusIndicator.d.ts.map +1 -1
  98. package/lib/typescript/src/camera/FocusIndicator.test.d.ts +2 -0
  99. package/lib/typescript/src/camera/FocusIndicator.test.d.ts.map +1 -0
  100. package/lib/typescript/src/camera/colors/dark.d.ts +18 -0
  101. package/lib/typescript/src/camera/colors/dark.d.ts.map +1 -0
  102. package/lib/typescript/src/camera/colors/dark.test.d.ts +2 -0
  103. package/lib/typescript/src/camera/colors/dark.test.d.ts.map +1 -0
  104. package/lib/typescript/src/camera/footer/ActionRow.d.ts +14 -0
  105. package/lib/typescript/src/camera/footer/ActionRow.d.ts.map +1 -0
  106. package/lib/typescript/src/camera/footer/ActionRow.test.d.ts +2 -0
  107. package/lib/typescript/src/camera/footer/ActionRow.test.d.ts.map +1 -0
  108. package/lib/typescript/src/camera/footer/FlipButton.d.ts +4 -0
  109. package/lib/typescript/src/camera/footer/FlipButton.d.ts.map +1 -0
  110. package/lib/typescript/src/camera/footer/FlipButton.test.d.ts +2 -0
  111. package/lib/typescript/src/camera/footer/FlipButton.test.d.ts.map +1 -0
  112. package/lib/typescript/src/camera/footer/ModeSwitcherPill.d.ts +10 -0
  113. package/lib/typescript/src/camera/footer/ModeSwitcherPill.d.ts.map +1 -0
  114. package/lib/typescript/src/camera/footer/ModeSwitcherPill.test.d.ts +2 -0
  115. package/lib/typescript/src/camera/footer/ModeSwitcherPill.test.d.ts.map +1 -0
  116. package/lib/typescript/src/camera/footer/RecordingTimer.d.ts +5 -0
  117. package/lib/typescript/src/camera/footer/RecordingTimer.d.ts.map +1 -0
  118. package/lib/typescript/src/camera/footer/RecordingTimer.test.d.ts +2 -0
  119. package/lib/typescript/src/camera/footer/RecordingTimer.test.d.ts.map +1 -0
  120. package/lib/typescript/src/camera/footer/Shutter.d.ts +9 -0
  121. package/lib/typescript/src/camera/footer/Shutter.d.ts.map +1 -0
  122. package/lib/typescript/src/camera/footer/Shutter.test.d.ts +2 -0
  123. package/lib/typescript/src/camera/footer/Shutter.test.d.ts.map +1 -0
  124. package/lib/typescript/src/camera/footer/ThumbnailStack.d.ts +8 -0
  125. package/lib/typescript/src/camera/footer/ThumbnailStack.d.ts.map +1 -0
  126. package/lib/typescript/src/camera/footer/ThumbnailStack.test.d.ts +2 -0
  127. package/lib/typescript/src/camera/footer/ThumbnailStack.test.d.ts.map +1 -0
  128. package/lib/typescript/src/camera/footer/ZoomChips.d.ts +5 -0
  129. package/lib/typescript/src/camera/footer/ZoomChips.d.ts.map +1 -0
  130. package/lib/typescript/src/camera/footer/ZoomChips.test.d.ts +2 -0
  131. package/lib/typescript/src/camera/footer/ZoomChips.test.d.ts.map +1 -0
  132. package/lib/typescript/src/camera/footer/index.d.ts +7 -1
  133. package/lib/typescript/src/camera/footer/index.d.ts.map +1 -1
  134. package/lib/typescript/src/camera/icons/VolumeIcon.d.ts +8 -0
  135. package/lib/typescript/src/camera/icons/VolumeIcon.d.ts.map +1 -0
  136. package/lib/typescript/src/camera/icons/VolumeIcon.test.d.ts +2 -0
  137. package/lib/typescript/src/camera/icons/VolumeIcon.test.d.ts.map +1 -0
  138. package/lib/typescript/src/camera/index.d.ts +3 -1
  139. package/lib/typescript/src/camera/index.d.ts.map +1 -1
  140. package/lib/typescript/src/camera/preview/PreviewBottomBar.d.ts +12 -0
  141. package/lib/typescript/src/camera/preview/PreviewBottomBar.d.ts.map +1 -0
  142. package/lib/typescript/src/camera/preview/PreviewBottomBar.test.d.ts +2 -0
  143. package/lib/typescript/src/camera/preview/PreviewBottomBar.test.d.ts.map +1 -0
  144. package/lib/typescript/src/camera/preview/PreviewOverlay.d.ts +12 -0
  145. package/lib/typescript/src/camera/preview/PreviewOverlay.d.ts.map +1 -0
  146. package/lib/typescript/src/camera/preview/PreviewOverlay.test.d.ts +2 -0
  147. package/lib/typescript/src/camera/preview/PreviewOverlay.test.d.ts.map +1 -0
  148. package/lib/typescript/src/camera/preview/PreviewTopBar.d.ts +10 -0
  149. package/lib/typescript/src/camera/preview/PreviewTopBar.d.ts.map +1 -0
  150. package/lib/typescript/src/camera/preview/PreviewTopBar.test.d.ts +2 -0
  151. package/lib/typescript/src/camera/preview/PreviewTopBar.test.d.ts.map +1 -0
  152. package/lib/typescript/src/camera/preview/groupTypes.d.ts +4 -0
  153. package/lib/typescript/src/camera/preview/groupTypes.d.ts.map +1 -0
  154. package/lib/typescript/src/camera/preview/groupTypes.test.d.ts +2 -0
  155. package/lib/typescript/src/camera/preview/groupTypes.test.d.ts.map +1 -0
  156. package/lib/typescript/src/camera/preview/index.d.ts +3 -4
  157. package/lib/typescript/src/camera/preview/index.d.ts.map +1 -1
  158. package/lib/typescript/src/camera/setup/SideRail.d.ts +15 -0
  159. package/lib/typescript/src/camera/setup/SideRail.d.ts.map +1 -0
  160. package/lib/typescript/src/camera/setup/SideRail.test.d.ts +2 -0
  161. package/lib/typescript/src/camera/setup/SideRail.test.d.ts.map +1 -0
  162. package/lib/typescript/src/camera/setup/index.d.ts +2 -1
  163. package/lib/typescript/src/camera/setup/index.d.ts.map +1 -1
  164. package/lib/typescript/src/components/Carousel/SlideItem.d.ts.map +1 -1
  165. package/lib/typescript/src/components/Carousel/SlideItem.test.d.ts +2 -0
  166. package/lib/typescript/src/components/Carousel/SlideItem.test.d.ts.map +1 -0
  167. package/lib/typescript/src/components/VideoPlayer.d.ts +4 -0
  168. package/lib/typescript/src/components/VideoPlayer.d.ts.map +1 -0
  169. package/lib/typescript/src/components/VideoPlayer.test.d.ts +2 -0
  170. package/lib/typescript/src/components/VideoPlayer.test.d.ts.map +1 -0
  171. package/lib/typescript/src/components/index.d.ts +0 -1
  172. package/lib/typescript/src/components/index.d.ts.map +1 -1
  173. package/lib/typescript/src/hooks/index.d.ts +0 -1
  174. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  175. package/package.json +7 -3
  176. package/src/camera/Camera.tsx +117 -26
  177. package/src/camera/CaptureFlash.test.tsx +11 -0
  178. package/src/camera/CaptureFlash.tsx +42 -0
  179. package/src/camera/Container.tsx +158 -64
  180. package/src/camera/FocusIndicator.test.tsx +17 -0
  181. package/src/camera/FocusIndicator.tsx +111 -27
  182. package/src/camera/colors/dark.test.ts +8 -0
  183. package/src/camera/colors/dark.ts +19 -0
  184. package/src/camera/footer/ActionRow.test.tsx +44 -0
  185. package/src/camera/footer/ActionRow.tsx +80 -0
  186. package/src/camera/footer/FlipButton.test.tsx +9 -0
  187. package/src/camera/footer/FlipButton.tsx +22 -0
  188. package/src/camera/footer/ModeSwitcherPill.test.tsx +29 -0
  189. package/src/camera/footer/ModeSwitcherPill.tsx +97 -0
  190. package/src/camera/footer/RecordingTimer.test.tsx +14 -0
  191. package/src/camera/footer/RecordingTimer.tsx +58 -0
  192. package/src/camera/footer/Shutter.test.tsx +26 -0
  193. package/src/camera/footer/Shutter.tsx +71 -0
  194. package/src/camera/footer/ThumbnailStack.test.tsx +19 -0
  195. package/src/camera/footer/ThumbnailStack.tsx +58 -0
  196. package/src/camera/footer/ZoomChips.test.tsx +9 -0
  197. package/src/camera/footer/ZoomChips.tsx +53 -0
  198. package/src/camera/footer/index.tsx +7 -1
  199. package/src/camera/icons/VolumeIcon.test.tsx +9 -0
  200. package/src/camera/icons/VolumeIcon.tsx +39 -0
  201. package/src/camera/index.tsx +11 -1
  202. package/src/camera/preview/PreviewBottomBar.test.tsx +43 -0
  203. package/src/camera/preview/PreviewBottomBar.tsx +79 -0
  204. package/src/camera/preview/PreviewOverlay.test.tsx +45 -0
  205. package/src/camera/preview/PreviewOverlay.tsx +99 -0
  206. package/src/camera/preview/PreviewTopBar.test.tsx +46 -0
  207. package/src/camera/preview/PreviewTopBar.tsx +91 -0
  208. package/src/camera/preview/groupTypes.test.ts +34 -0
  209. package/src/camera/preview/groupTypes.ts +15 -0
  210. package/src/camera/preview/index.tsx +3 -4
  211. package/src/camera/setup/SideRail.test.tsx +37 -0
  212. package/src/camera/setup/SideRail.tsx +161 -0
  213. package/src/camera/setup/index.tsx +2 -1
  214. package/src/components/Carousel/SlideItem.test.tsx +27 -0
  215. package/src/components/Carousel/SlideItem.tsx +11 -7
  216. package/src/components/VideoPlayer.test.tsx +8 -0
  217. package/src/components/VideoPlayer.tsx +30 -0
  218. package/src/components/index.tsx +0 -1
  219. package/src/hooks/index.ts +0 -1
  220. package/lib/module/camera/footer/Footer.js +0 -94
  221. package/lib/module/camera/footer/Footer.js.map +0 -1
  222. package/lib/module/camera/preview/PreView.js +0 -43
  223. package/lib/module/camera/preview/PreView.js.map +0 -1
  224. package/lib/module/camera/preview/PreViewContainer.js +0 -33
  225. package/lib/module/camera/preview/PreViewContainer.js.map +0 -1
  226. package/lib/module/camera/preview/PreviewFooter.js +0 -36
  227. package/lib/module/camera/preview/PreviewFooter.js.map +0 -1
  228. package/lib/module/camera/preview/SinglePre.js +0 -32
  229. package/lib/module/camera/preview/SinglePre.js.map +0 -1
  230. package/lib/module/camera/setup/SetUp.js +0 -74
  231. package/lib/module/camera/setup/SetUp.js.map +0 -1
  232. package/lib/module/components/PreviewThumbnail.js +0 -39
  233. package/lib/module/components/PreviewThumbnail.js.map +0 -1
  234. package/lib/module/hooks/useConfirm.js +0 -16
  235. package/lib/module/hooks/useConfirm.js.map +0 -1
  236. package/lib/typescript/src/camera/footer/Footer.d.ts +0 -14
  237. package/lib/typescript/src/camera/footer/Footer.d.ts.map +0 -1
  238. package/lib/typescript/src/camera/preview/PreView.d.ts +0 -7
  239. package/lib/typescript/src/camera/preview/PreView.d.ts.map +0 -1
  240. package/lib/typescript/src/camera/preview/PreViewContainer.d.ts +0 -9
  241. package/lib/typescript/src/camera/preview/PreViewContainer.d.ts.map +0 -1
  242. package/lib/typescript/src/camera/preview/PreviewFooter.d.ts +0 -7
  243. package/lib/typescript/src/camera/preview/PreviewFooter.d.ts.map +0 -1
  244. package/lib/typescript/src/camera/preview/SinglePre.d.ts +0 -7
  245. package/lib/typescript/src/camera/preview/SinglePre.d.ts.map +0 -1
  246. package/lib/typescript/src/camera/setup/SetUp.d.ts +0 -13
  247. package/lib/typescript/src/camera/setup/SetUp.d.ts.map +0 -1
  248. package/lib/typescript/src/components/PreviewThumbnail.d.ts +0 -9
  249. package/lib/typescript/src/components/PreviewThumbnail.d.ts.map +0 -1
  250. package/lib/typescript/src/hooks/useConfirm.d.ts +0 -2
  251. package/lib/typescript/src/hooks/useConfirm.d.ts.map +0 -1
  252. package/src/camera/footer/Footer.tsx +0 -113
  253. package/src/camera/preview/PreView.tsx +0 -32
  254. package/src/camera/preview/PreViewContainer.tsx +0 -30
  255. package/src/camera/preview/PreviewFooter.tsx +0 -38
  256. package/src/camera/preview/SinglePre.tsx +0 -30
  257. package/src/camera/setup/SetUp.tsx +0 -93
  258. package/src/components/PreviewThumbnail.tsx +0 -45
  259. package/src/hooks/useConfirm.tsx +0 -11
@@ -0,0 +1,22 @@
1
+ import { StyleSheet, TouchableOpacity } from 'react-native';
2
+ import { Icon, r } from '@unif/react-native-design';
3
+ import { DARK } from '../colors/dark';
4
+
5
+ export function FlipButton({ onFlip }: { onFlip: () => void }) {
6
+ return (
7
+ <TouchableOpacity testID="flip-btn" onPress={onFlip} style={styles.btn}>
8
+ <Icon name="lens-flip" size={r(20)} color={DARK.white} />
9
+ </TouchableOpacity>
10
+ );
11
+ }
12
+
13
+ const styles = StyleSheet.create({
14
+ btn: {
15
+ width: r(44),
16
+ height: r(44),
17
+ borderRadius: r(22),
18
+ backgroundColor: DARK.white12,
19
+ alignItems: 'center',
20
+ justifyContent: 'center',
21
+ },
22
+ });
@@ -0,0 +1,29 @@
1
+ import { render, fireEvent } from '@testing-library/react-native';
2
+ import { ModeSwitcherPill } from './ModeSwitcherPill';
3
+
4
+ const items = [
5
+ { key: 'continuous', label: '连拍' },
6
+ { key: 'single', label: '单拍' },
7
+ { key: 'video', label: '视频' },
8
+ ];
9
+
10
+ it('selects by tap', () => {
11
+ const onSelect = jest.fn();
12
+ const { getByTestId } = render(
13
+ <ModeSwitcherPill items={items} currentIndex={1} onSelect={onSelect} />
14
+ );
15
+ fireEvent.press(getByTestId('mode-pill-2'));
16
+ expect(onSelect).toHaveBeenCalledWith(2);
17
+ });
18
+
19
+ it('single item shows label only (no pill)', () => {
20
+ const { queryByTestId, getByText } = render(
21
+ <ModeSwitcherPill
22
+ items={[{ key: 'single', label: '单拍' }]}
23
+ currentIndex={0}
24
+ onSelect={() => {}}
25
+ />
26
+ );
27
+ expect(queryByTestId('mode-pill-0')).toBeNull();
28
+ expect(getByText('单拍')).toBeTruthy();
29
+ });
@@ -0,0 +1,97 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import {
3
+ Animated,
4
+ StyleSheet,
5
+ Text,
6
+ TouchableOpacity,
7
+ View,
8
+ } from 'react-native';
9
+ import type { LayoutChangeEvent } from 'react-native';
10
+ import { r } from '@unif/react-native-design';
11
+ import { DARK } from '../colors/dark';
12
+
13
+ export type ModeItem = { key: string; label: string };
14
+
15
+ export function ModeSwitcherPill({
16
+ items,
17
+ currentIndex,
18
+ onSelect,
19
+ }: {
20
+ items: ModeItem[];
21
+ currentIndex: number;
22
+ onSelect: (i: number) => void;
23
+ }) {
24
+ const [w, setW] = useState(0);
25
+ const slide = useRef(new Animated.Value(0)).current;
26
+ const itemW = items.length ? w / items.length : 0;
27
+
28
+ useEffect(() => {
29
+ Animated.timing(slide, {
30
+ toValue: currentIndex * itemW,
31
+ duration: 240,
32
+ useNativeDriver: true,
33
+ }).start();
34
+ }, [currentIndex, itemW, slide]);
35
+
36
+ if (items.length === 1) {
37
+ return <Text style={styles.singleLabel}>{items[0]!.label}</Text>;
38
+ }
39
+ return (
40
+ <View
41
+ style={styles.wrap}
42
+ onLayout={(e: LayoutChangeEvent) => setW(e.nativeEvent.layout.width)}
43
+ >
44
+ {itemW > 0 && (
45
+ <Animated.View
46
+ style={[
47
+ styles.slider,
48
+ { width: itemW, transform: [{ translateX: slide }] },
49
+ ]}
50
+ />
51
+ )}
52
+ {items.map((it, i) => {
53
+ const sel = i === currentIndex;
54
+ return (
55
+ <TouchableOpacity
56
+ key={it.key}
57
+ testID={`mode-pill-${i}`}
58
+ style={styles.item}
59
+ onPress={() => onSelect(i)}
60
+ >
61
+ <Text style={[styles.txt, sel && styles.txtSel]}>{it.label}</Text>
62
+ </TouchableOpacity>
63
+ );
64
+ })}
65
+ </View>
66
+ );
67
+ }
68
+
69
+ const styles = StyleSheet.create({
70
+ wrap: { flexDirection: 'row', alignSelf: 'center', position: 'relative' },
71
+ slider: {
72
+ position: 'absolute',
73
+ top: r(4),
74
+ bottom: r(4),
75
+ left: 0,
76
+ borderRadius: r(999),
77
+ backgroundColor: DARK.orange16,
78
+ },
79
+ item: {
80
+ paddingVertical: r(8),
81
+ paddingHorizontal: r(22),
82
+ alignItems: 'center',
83
+ },
84
+ txt: {
85
+ color: DARK.white65,
86
+ fontSize: r(15),
87
+ fontWeight: '500',
88
+ letterSpacing: 1,
89
+ },
90
+ txtSel: { color: DARK.orange, fontWeight: '600' },
91
+ singleLabel: {
92
+ color: DARK.white65,
93
+ fontSize: r(14),
94
+ letterSpacing: 2,
95
+ alignSelf: 'center',
96
+ },
97
+ });
@@ -0,0 +1,14 @@
1
+ import { render } from '@testing-library/react-native';
2
+ import { RecordingTimer, formatDuration } from './RecordingTimer';
3
+
4
+ it('formatDuration → MM:SS', () => {
5
+ expect(formatDuration(0)).toBe('00:00');
6
+ expect(formatDuration(65)).toBe('01:05');
7
+ expect(formatDuration(3661)).toBe('61:01');
8
+ expect(formatDuration(-5)).toBe('00:00');
9
+ });
10
+
11
+ it('renders timer pill', () => {
12
+ const { getByTestId } = render(<RecordingTimer seconds={5} />);
13
+ expect(getByTestId('recording-timer')).toBeTruthy();
14
+ });
@@ -0,0 +1,58 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { Animated, StyleSheet, Text, View } from 'react-native';
3
+ import { r } from '@unif/react-native-design';
4
+ import { DARK } from '../colors/dark';
5
+
6
+ export function formatDuration(totalSec: number): string {
7
+ const s = Math.max(0, Math.floor(totalSec));
8
+ const mm = String(Math.floor(s / 60)).padStart(2, '0');
9
+ const ss = String(s % 60).padStart(2, '0');
10
+ return `${mm}:${ss}`;
11
+ }
12
+
13
+ export function RecordingTimer({ seconds }: { seconds: number }) {
14
+ const blink = useRef(new Animated.Value(1)).current;
15
+ useEffect(() => {
16
+ const loop = Animated.loop(
17
+ Animated.sequence([
18
+ Animated.timing(blink, {
19
+ toValue: 0,
20
+ duration: 500,
21
+ useNativeDriver: true,
22
+ }),
23
+ Animated.timing(blink, {
24
+ toValue: 1,
25
+ duration: 500,
26
+ useNativeDriver: true,
27
+ }),
28
+ ])
29
+ );
30
+ loop.start();
31
+ return () => loop.stop();
32
+ }, [blink]);
33
+ return (
34
+ <View style={styles.pill} testID="recording-timer">
35
+ <Animated.View style={[styles.dot, { opacity: blink }]} />
36
+ <Text style={styles.text}>{formatDuration(seconds)}</Text>
37
+ </View>
38
+ );
39
+ }
40
+
41
+ const styles = StyleSheet.create({
42
+ pill: {
43
+ flexDirection: 'row',
44
+ alignItems: 'center',
45
+ gap: r(8),
46
+ paddingVertical: r(6),
47
+ paddingHorizontal: r(14),
48
+ borderRadius: r(999),
49
+ backgroundColor: 'rgba(255,59,48,0.18)',
50
+ },
51
+ dot: {
52
+ width: r(8),
53
+ height: r(8),
54
+ borderRadius: r(4),
55
+ backgroundColor: DARK.recRed,
56
+ },
57
+ text: { color: DARK.white, fontSize: r(13), fontVariant: ['tabular-nums'] },
58
+ });
@@ -0,0 +1,26 @@
1
+ import { render, fireEvent } from '@testing-library/react-native';
2
+ import { Shutter } from './Shutter';
3
+
4
+ it('fires onPress and renders for each mode', () => {
5
+ const onPress = jest.fn();
6
+ const { getByTestId, rerender } = render(
7
+ <Shutter mode="single" recording={false} onPress={onPress} />
8
+ );
9
+ fireEvent.press(getByTestId('shutter-btn'));
10
+ expect(onPress).toHaveBeenCalledTimes(1);
11
+ expect(() =>
12
+ rerender(<Shutter mode="video" recording={false} onPress={onPress} />)
13
+ ).not.toThrow();
14
+ expect(() =>
15
+ rerender(<Shutter mode="video" recording={true} onPress={onPress} />)
16
+ ).not.toThrow();
17
+ });
18
+
19
+ it('disabled blocks press', () => {
20
+ const onPress = jest.fn();
21
+ const { getByTestId } = render(
22
+ <Shutter mode="single" recording={false} disabled onPress={onPress} />
23
+ );
24
+ fireEvent.press(getByTestId('shutter-btn'));
25
+ expect(onPress).not.toHaveBeenCalled();
26
+ });
@@ -0,0 +1,71 @@
1
+ import { useRef } from 'react';
2
+ import { Animated, Pressable, StyleSheet } from 'react-native';
3
+ import { r } from '@unif/react-native-design';
4
+ import { DARK } from '../colors/dark';
5
+
6
+ type Props = {
7
+ mode: 'single' | 'continuous' | 'video';
8
+ recording: boolean;
9
+ disabled?: boolean;
10
+ onPress: () => void;
11
+ };
12
+
13
+ export function Shutter({ mode, recording, disabled, onPress }: Props) {
14
+ const scale = useRef(new Animated.Value(1)).current;
15
+ const to = (v: number) =>
16
+ Animated.timing(scale, {
17
+ toValue: v,
18
+ duration: 100,
19
+ useNativeDriver: true,
20
+ }).start();
21
+ const isVideo = mode === 'video';
22
+ const inner = recording
23
+ ? styles.innerRecording
24
+ : isVideo
25
+ ? styles.innerVideo
26
+ : styles.innerPhoto;
27
+ return (
28
+ <Pressable
29
+ testID="shutter-btn"
30
+ disabled={disabled}
31
+ onPress={onPress}
32
+ onPressIn={() => to(0.94)}
33
+ onPressOut={() => to(1)}
34
+ >
35
+ <Animated.View style={[styles.ring, { transform: [{ scale }] }]}>
36
+ <Animated.View style={[styles.innerBase, inner]} />
37
+ </Animated.View>
38
+ </Pressable>
39
+ );
40
+ }
41
+
42
+ const styles = StyleSheet.create({
43
+ ring: {
44
+ width: r(72),
45
+ height: r(72),
46
+ borderRadius: r(36),
47
+ borderWidth: r(3),
48
+ borderColor: DARK.white95,
49
+ alignItems: 'center',
50
+ justifyContent: 'center',
51
+ },
52
+ innerBase: {},
53
+ innerPhoto: {
54
+ width: r(58),
55
+ height: r(58),
56
+ borderRadius: r(29),
57
+ backgroundColor: DARK.white,
58
+ },
59
+ innerVideo: {
60
+ width: r(58),
61
+ height: r(58),
62
+ borderRadius: r(29),
63
+ backgroundColor: DARK.recRed,
64
+ },
65
+ innerRecording: {
66
+ width: r(24),
67
+ height: r(24),
68
+ borderRadius: r(4),
69
+ backgroundColor: DARK.recRed,
70
+ },
71
+ });
@@ -0,0 +1,19 @@
1
+ import { render, fireEvent } from '@testing-library/react-native';
2
+ import { ThumbnailStack } from './ThumbnailStack';
3
+
4
+ it('empty state, no badge, tappable', () => {
5
+ const onPress = jest.fn();
6
+ const { getByTestId, queryByTestId } = render(
7
+ <ThumbnailStack latestUri={undefined} count={0} onPress={onPress} />
8
+ );
9
+ fireEvent.press(getByTestId('thumbnail-stack'));
10
+ expect(onPress).toHaveBeenCalled();
11
+ expect(queryByTestId('thumb-badge')).toBeNull();
12
+ });
13
+
14
+ it('shows badge when count > 1', () => {
15
+ const { getByTestId } = render(
16
+ <ThumbnailStack latestUri="file:///a.jpg" count={3} onPress={() => {}} />
17
+ );
18
+ expect(getByTestId('thumb-badge')).toBeTruthy();
19
+ });
@@ -0,0 +1,58 @@
1
+ import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
2
+ import { r } from '@unif/react-native-design';
3
+ import { DARK } from '../colors/dark';
4
+
5
+ type Props = { latestUri?: string; count: number; onPress: () => void };
6
+
7
+ export function ThumbnailStack({ latestUri, count, onPress }: Props) {
8
+ return (
9
+ <TouchableOpacity
10
+ testID="thumbnail-stack"
11
+ onPress={onPress}
12
+ style={styles.wrap}
13
+ >
14
+ {latestUri ? (
15
+ <Image source={{ uri: latestUri }} style={styles.img} />
16
+ ) : (
17
+ <View style={styles.empty} />
18
+ )}
19
+ {count > 1 && (
20
+ <View style={styles.badge} testID="thumb-badge">
21
+ <Text style={styles.badgeText}>{count}</Text>
22
+ </View>
23
+ )}
24
+ </TouchableOpacity>
25
+ );
26
+ }
27
+
28
+ const styles = StyleSheet.create({
29
+ wrap: { width: r(44), height: r(44) },
30
+ img: {
31
+ width: r(44),
32
+ height: r(44),
33
+ borderRadius: r(6),
34
+ borderWidth: 2,
35
+ borderColor: DARK.white,
36
+ },
37
+ empty: {
38
+ width: r(44),
39
+ height: r(44),
40
+ borderRadius: r(8),
41
+ borderWidth: 1.5,
42
+ borderColor: DARK.white40,
43
+ backgroundColor: DARK.white08,
44
+ },
45
+ badge: {
46
+ position: 'absolute',
47
+ top: r(-6),
48
+ right: r(-6),
49
+ minWidth: r(20),
50
+ height: r(20),
51
+ borderRadius: r(999),
52
+ backgroundColor: DARK.orange,
53
+ alignItems: 'center',
54
+ justifyContent: 'center',
55
+ paddingHorizontal: r(4),
56
+ },
57
+ badgeText: { color: DARK.white, fontSize: r(11), fontWeight: '700' },
58
+ });
@@ -0,0 +1,9 @@
1
+ import { render, fireEvent } from '@testing-library/react-native';
2
+ import { ZoomChips } from './ZoomChips';
3
+
4
+ it('renders 3 chips and selects', () => {
5
+ const onSelect = jest.fn();
6
+ const { getByTestId } = render(<ZoomChips zoom={1} onSelect={onSelect} />);
7
+ fireEvent.press(getByTestId('zoom-chip-2'));
8
+ expect(onSelect).toHaveBeenCalledWith(2);
9
+ });
@@ -0,0 +1,53 @@
1
+ import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
2
+ import { r } from '@unif/react-native-design';
3
+ import { DARK } from '../colors/dark';
4
+
5
+ const STOPS = [0.5, 1, 2] as const;
6
+
7
+ export function ZoomChips({
8
+ zoom,
9
+ onSelect,
10
+ }: {
11
+ zoom: number;
12
+ onSelect: (z: number) => void;
13
+ }) {
14
+ return (
15
+ <View style={styles.row}>
16
+ {STOPS.map((z) => {
17
+ const active = Math.abs(zoom - z) < 0.05;
18
+ return (
19
+ <TouchableOpacity
20
+ key={z}
21
+ testID={`zoom-chip-${z}`}
22
+ onPress={() => onSelect(z)}
23
+ style={[styles.chip, active && styles.chipActive]}
24
+ >
25
+ <Text style={[styles.txt, active && styles.txtActive]}>
26
+ {active ? `${z}x` : `${z}`}
27
+ </Text>
28
+ </TouchableOpacity>
29
+ );
30
+ })}
31
+ </View>
32
+ );
33
+ }
34
+
35
+ const styles = StyleSheet.create({
36
+ row: {
37
+ flexDirection: 'row',
38
+ gap: r(8),
39
+ padding: r(4),
40
+ borderRadius: r(999),
41
+ backgroundColor: DARK.black45,
42
+ },
43
+ chip: {
44
+ width: r(32),
45
+ height: r(32),
46
+ borderRadius: r(16),
47
+ alignItems: 'center',
48
+ justifyContent: 'center',
49
+ },
50
+ chipActive: { backgroundColor: DARK.white95 },
51
+ txt: { color: DARK.white, fontSize: r(12), fontWeight: '500' },
52
+ txtActive: { color: DARK.orange, fontSize: r(11), fontWeight: '700' },
53
+ });
@@ -1 +1,7 @@
1
- export { Footer } from './Footer';
1
+ export { ActionRow } from './ActionRow';
2
+ export { ModeSwitcherPill } from './ModeSwitcherPill';
3
+ export { Shutter } from './Shutter';
4
+ export { ZoomChips } from './ZoomChips';
5
+ export { RecordingTimer } from './RecordingTimer';
6
+ export { ThumbnailStack } from './ThumbnailStack';
7
+ export { FlipButton } from './FlipButton';
@@ -0,0 +1,9 @@
1
+ import { render } from '@testing-library/react-native';
2
+ import { VolumeIcon } from './VolumeIcon';
3
+
4
+ it('renders without crashing (on/off)', () => {
5
+ expect(() => render(<VolumeIcon on size={20} color="#fff" />)).not.toThrow();
6
+ expect(() =>
7
+ render(<VolumeIcon on={false} size={20} color="#fff" />)
8
+ ).not.toThrow();
9
+ });
@@ -0,0 +1,39 @@
1
+ import Svg, { Path, Line } from 'react-native-svg';
2
+
3
+ type Props = { on: boolean; size: number; color: string };
4
+
5
+ export function VolumeIcon({ on, size, color }: Props) {
6
+ return (
7
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
8
+ {/* 喇叭主体 */}
9
+ <Path
10
+ d="M4 9v6h4l5 4V5L8 9H4z"
11
+ fill={color}
12
+ stroke={color}
13
+ strokeWidth={1.6}
14
+ strokeLinejoin="round"
15
+ />
16
+ {on ? (
17
+ // 声波两道
18
+ <Path
19
+ d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12"
20
+ stroke={color}
21
+ strokeWidth={1.6}
22
+ strokeLinecap="round"
23
+ fill="none"
24
+ />
25
+ ) : (
26
+ // 关:斜杠
27
+ <Line
28
+ x1={16}
29
+ y1={8}
30
+ x2={21}
31
+ y2={16}
32
+ stroke={color}
33
+ strokeWidth={1.6}
34
+ strokeLinecap="round"
35
+ />
36
+ )}
37
+ </Svg>
38
+ );
39
+ }
@@ -9,4 +9,14 @@ export {
9
9
  capturePhotoToFile,
10
10
  type CapturedPhotoRaw,
11
11
  } from './capturePhotoHelper';
12
- export { Footer } from './footer';
12
+ export {
13
+ ActionRow,
14
+ ModeSwitcherPill,
15
+ Shutter,
16
+ ZoomChips,
17
+ RecordingTimer,
18
+ ThumbnailStack,
19
+ FlipButton,
20
+ } from './footer';
21
+ export { CaptureFlash } from './CaptureFlash';
22
+ export { SideRail, type FlashMode, type AspectRatio } from './setup';
@@ -0,0 +1,43 @@
1
+ import { render, fireEvent } from '@testing-library/react-native';
2
+ import { PreviewBottomBar } from './PreviewBottomBar';
3
+
4
+ it('confirm 变体: 重拍/保存', () => {
5
+ const onRetake = jest.fn();
6
+ const onSave = jest.fn();
7
+ const { getByTestId } = render(
8
+ <PreviewBottomBar
9
+ variant="confirm"
10
+ index={0}
11
+ total={1}
12
+ onRetake={onRetake}
13
+ onSave={onSave}
14
+ onBack={() => {}}
15
+ onDelete={() => {}}
16
+ />
17
+ );
18
+ fireEvent.press(getByTestId('retake-btn'));
19
+ expect(onRetake).toHaveBeenCalled();
20
+ fireEvent.press(getByTestId('save-btn'));
21
+ expect(onSave).toHaveBeenCalled();
22
+ });
23
+
24
+ it('gallery 变体: 第X/Y张 + 返回/删除', () => {
25
+ const onBack = jest.fn();
26
+ const onDelete = jest.fn();
27
+ const { getByTestId, getByText } = render(
28
+ <PreviewBottomBar
29
+ variant="gallery"
30
+ index={1}
31
+ total={3}
32
+ onRetake={() => {}}
33
+ onSave={() => {}}
34
+ onBack={onBack}
35
+ onDelete={onDelete}
36
+ />
37
+ );
38
+ expect(getByText('第 2/3 张')).toBeTruthy();
39
+ fireEvent.press(getByTestId('back-btn'));
40
+ expect(onBack).toHaveBeenCalled();
41
+ fireEvent.press(getByTestId('delete-btn'));
42
+ expect(onDelete).toHaveBeenCalled();
43
+ });
@@ -0,0 +1,79 @@
1
+ import { StyleSheet, Text, View } from 'react-native';
2
+ import { Button, r, useThemedStyles } from '@unif/react-native-design';
3
+ import type { ColorTokens } from '@unif/react-native-design';
4
+
5
+ type Props = {
6
+ variant: 'confirm' | 'gallery';
7
+ index: number;
8
+ total: number;
9
+ onRetake: () => void;
10
+ onSave: () => void;
11
+ onBack: () => void;
12
+ onDelete: () => void;
13
+ };
14
+
15
+ export function PreviewBottomBar({
16
+ variant,
17
+ index,
18
+ total,
19
+ onRetake,
20
+ onSave,
21
+ onBack,
22
+ onDelete,
23
+ }: Props) {
24
+ const styles = useThemedStyles(makeStyles);
25
+ return (
26
+ <View style={styles.root}>
27
+ {variant === 'gallery' && (
28
+ <Text style={styles.counter}>
29
+ 第 {index + 1}/{total} 张
30
+ </Text>
31
+ )}
32
+ <View style={styles.btns}>
33
+ {variant === 'confirm' ? (
34
+ <>
35
+ <Button
36
+ variant="ghost"
37
+ label="重拍"
38
+ onPress={onRetake}
39
+ testID="retake-btn"
40
+ />
41
+ <Button
42
+ variant="primary"
43
+ label="保存"
44
+ onPress={onSave}
45
+ testID="save-btn"
46
+ />
47
+ </>
48
+ ) : (
49
+ <>
50
+ <Button
51
+ variant="ghost"
52
+ label="返回"
53
+ onPress={onBack}
54
+ testID="back-btn"
55
+ />
56
+ <Button
57
+ variant="danger"
58
+ label="删除"
59
+ onPress={onDelete}
60
+ testID="delete-btn"
61
+ />
62
+ </>
63
+ )}
64
+ </View>
65
+ </View>
66
+ );
67
+ }
68
+
69
+ const makeStyles = (c: ColorTokens) =>
70
+ StyleSheet.create({
71
+ root: {
72
+ paddingHorizontal: r(16),
73
+ paddingBottom: r(26),
74
+ gap: r(12),
75
+ alignItems: 'center',
76
+ },
77
+ counter: { color: c.foreground, fontSize: r(15), fontWeight: '600' },
78
+ btns: { flexDirection: 'row', gap: r(12), justifyContent: 'center' },
79
+ });