@neko-os/ui 0.5.4 → 0.6.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 (135) hide show
  1. package/dist/NekoUI.js +12 -9
  2. package/dist/abstractions/WindowOverlay.js +3 -0
  3. package/dist/abstractions/WindowOverlay.native.js +21 -0
  4. package/dist/abstractions/helpers/storage.js +14 -4
  5. package/dist/abstractions/helpers/storage.native.js +9 -1
  6. package/dist/components/feedback/notifications/NotificationsHandler.js +10 -6
  7. package/dist/components/index.js +3 -1
  8. package/dist/components/inputs/DateInput.js +10 -6
  9. package/dist/components/inputs/InputWrapper.js +2 -3
  10. package/dist/components/inputs/NumberWheelInput.js +50 -0
  11. package/dist/components/inputs/NumberWheelPicker.js +43 -0
  12. package/dist/components/inputs/UploadInput.js +4 -4
  13. package/dist/components/inputs/WheelPicker.js +49 -0
  14. package/dist/components/inputs/WheelPicker.native.js +88 -0
  15. package/dist/components/inputs/WheelPicker.web.js +1 -0
  16. package/dist/components/inputs/dateWheelPicker/DateWheelPicker.js +24 -0
  17. package/dist/components/inputs/dateWheelPicker/DayWheelPicker.js +48 -0
  18. package/dist/components/inputs/dateWheelPicker/MonthWheelPicker.js +19 -0
  19. package/dist/components/inputs/dateWheelPicker/QuarterWheelPicker.js +61 -0
  20. package/dist/components/inputs/dateWheelPicker/WeekWheelPicker.js +66 -0
  21. package/dist/components/inputs/dateWheelPicker/YearWheelPicker.js +35 -0
  22. package/dist/components/inputs/index.js +5 -1
  23. package/dist/components/inputs/upload/Upload.native.js +60 -52
  24. package/dist/components/inputs/upload/useUploadState.js +11 -3
  25. package/dist/components/measurements/FeetInchesInput.js +91 -0
  26. package/dist/components/measurements/LengthInput.js +32 -0
  27. package/dist/components/measurements/LengthText.js +10 -0
  28. package/dist/components/measurements/MeasurementHandler.js +26 -0
  29. package/dist/components/measurements/WeightInput.js +25 -0
  30. package/dist/components/measurements/WeightText.js +10 -0
  31. package/dist/components/measurements/helpers/detectMeasurementSystem.js +15 -0
  32. package/dist/components/measurements/helpers/detectMeasurementSystem.native.js +9 -0
  33. package/dist/components/measurements/helpers/index.js +2 -0
  34. package/dist/components/measurements/helpers/length.js +112 -0
  35. package/dist/components/measurements/helpers/weight.js +56 -0
  36. package/dist/components/measurements/index.js +9 -0
  37. package/dist/components/measurements/useLengthFormatter.js +35 -0
  38. package/dist/components/measurements/useLocalInputValue.js +32 -0
  39. package/dist/components/measurements/useWeightFormatter.js +29 -0
  40. package/dist/components/routing/ReturnButton.js +20 -0
  41. package/dist/components/routing/ReturnButton.native.js +20 -0
  42. package/dist/components/routing/ReturnButton.web.js +2 -0
  43. package/dist/components/routing/ReturnLink.js +25 -0
  44. package/dist/components/routing/ReturnLink.native.js +25 -0
  45. package/dist/components/routing/ReturnLink.web.js +2 -0
  46. package/dist/components/routing/RoutedStepsContent.js +21 -0
  47. package/dist/components/routing/RoutedStepsContent.native.js +94 -0
  48. package/dist/components/routing/RoutedStepsContent.web.js +3 -0
  49. package/dist/components/routing/index.js +3 -0
  50. package/dist/components/state/StatePresenter.js +1 -1
  51. package/dist/components/steps/StepsHandler.js +2 -0
  52. package/dist/components/structure/TopBar.js +18 -16
  53. package/dist/components/theme/ThemePickerDrawer.js +1 -1
  54. package/dist/helpers/compress.js +61 -0
  55. package/dist/helpers/compress.native.js +49 -0
  56. package/dist/helpers/files.js +7 -0
  57. package/dist/helpers/files.native.js +55 -0
  58. package/dist/helpers/index.js +6 -1
  59. package/dist/helpers/media.js +4 -0
  60. package/dist/helpers/media.native.js +41 -0
  61. package/dist/helpers/numbers.js +13 -0
  62. package/dist/helpers/pickAssets.js +7 -0
  63. package/dist/helpers/pickAssets.native.js +66 -0
  64. package/dist/helpers/storage.js +17 -0
  65. package/dist/i18n/I18n.js +4 -4
  66. package/dist/index.js +1 -1
  67. package/dist/responsive/responsiveHooks.js +14 -0
  68. package/package.json +2 -14
  69. package/src/NekoUI.js +16 -13
  70. package/src/abstractions/WindowOverlay.js +3 -0
  71. package/src/abstractions/WindowOverlay.native.js +21 -0
  72. package/src/abstractions/helpers/storage.js +13 -3
  73. package/src/abstractions/helpers/storage.native.js +8 -0
  74. package/src/components/feedback/notifications/NotificationsHandler.js +12 -8
  75. package/src/components/index.js +2 -0
  76. package/src/components/inputs/DateInput.js +8 -4
  77. package/src/components/inputs/InputWrapper.js +1 -2
  78. package/src/components/inputs/NumberWheelInput.js +50 -0
  79. package/src/components/inputs/NumberWheelPicker.js +43 -0
  80. package/src/components/inputs/UploadInput.js +2 -2
  81. package/src/components/inputs/WheelPicker.js +49 -0
  82. package/src/components/inputs/WheelPicker.native.js +88 -0
  83. package/src/components/inputs/WheelPicker.web.js +1 -0
  84. package/src/components/inputs/dateWheelPicker/DateWheelPicker.js +24 -0
  85. package/src/components/inputs/dateWheelPicker/DayWheelPicker.js +48 -0
  86. package/src/components/inputs/dateWheelPicker/MonthWheelPicker.js +19 -0
  87. package/src/components/inputs/dateWheelPicker/QuarterWheelPicker.js +61 -0
  88. package/src/components/inputs/dateWheelPicker/WeekWheelPicker.js +66 -0
  89. package/src/components/inputs/dateWheelPicker/YearWheelPicker.js +35 -0
  90. package/src/components/inputs/index.js +4 -0
  91. package/src/components/inputs/upload/Upload.native.js +58 -50
  92. package/src/components/inputs/upload/useUploadState.js +11 -3
  93. package/src/components/measurements/FeetInchesInput.js +91 -0
  94. package/src/components/measurements/LengthInput.js +32 -0
  95. package/src/components/measurements/LengthText.js +10 -0
  96. package/src/components/measurements/MeasurementHandler.js +26 -0
  97. package/src/components/measurements/WeightInput.js +25 -0
  98. package/src/components/measurements/WeightText.js +10 -0
  99. package/src/components/measurements/helpers/detectMeasurementSystem.js +15 -0
  100. package/src/components/measurements/helpers/detectMeasurementSystem.native.js +9 -0
  101. package/src/components/measurements/helpers/index.js +2 -0
  102. package/src/components/measurements/helpers/length.js +112 -0
  103. package/src/components/measurements/helpers/weight.js +56 -0
  104. package/src/components/measurements/index.js +9 -0
  105. package/src/components/measurements/useLengthFormatter.js +35 -0
  106. package/src/components/measurements/useLocalInputValue.js +32 -0
  107. package/src/components/measurements/useWeightFormatter.js +29 -0
  108. package/src/components/routing/ReturnButton.js +20 -0
  109. package/src/components/routing/ReturnButton.native.js +20 -0
  110. package/src/components/routing/ReturnButton.web.js +2 -0
  111. package/src/components/routing/ReturnLink.js +25 -0
  112. package/src/components/routing/ReturnLink.native.js +25 -0
  113. package/src/components/routing/ReturnLink.web.js +2 -0
  114. package/src/components/routing/RoutedStepsContent.js +21 -0
  115. package/src/components/routing/RoutedStepsContent.native.js +94 -0
  116. package/src/components/routing/RoutedStepsContent.web.js +3 -0
  117. package/src/components/routing/index.js +3 -0
  118. package/src/components/state/StatePresenter.js +1 -1
  119. package/src/components/steps/StepsHandler.js +2 -0
  120. package/src/components/structure/TopBar.js +16 -14
  121. package/src/components/theme/ThemePickerDrawer.js +1 -1
  122. package/src/helpers/compress.js +61 -0
  123. package/src/helpers/compress.native.js +49 -0
  124. package/src/helpers/files.js +7 -0
  125. package/src/helpers/files.native.js +55 -0
  126. package/src/helpers/index.js +6 -1
  127. package/src/helpers/media.js +4 -0
  128. package/src/helpers/media.native.js +41 -0
  129. package/src/helpers/numbers.js +13 -0
  130. package/src/helpers/pickAssets.js +7 -0
  131. package/src/helpers/pickAssets.native.js +66 -0
  132. package/src/helpers/storage.js +17 -0
  133. package/src/i18n/I18n.js +2 -2
  134. package/src/index.js +1 -1
  135. package/src/responsive/responsiveHooks.js +14 -0
@@ -0,0 +1,29 @@
1
+ import { WEIGHT_CONVERTERS, WEIGHT_IMPERIAL_DEFAULTS } from './helpers/weight'
2
+ import { fixedDecimals } from '../../helpers/numbers'
3
+ import { useIsImperial } from './MeasurementHandler'
4
+
5
+ export function useWeightFormatter({
6
+ measurementSystem,
7
+ metricPrecision = 'kg',
8
+ imperialPrecision,
9
+ withoutSuffix,
10
+ } = {}) {
11
+ const isImperial = useIsImperial(measurementSystem)
12
+ const impPrec = imperialPrecision || WEIGHT_IMPERIAL_DEFAULTS[metricPrecision] || 'lbs'
13
+
14
+ return (value) => {
15
+ if (!value && value !== 0) return null
16
+
17
+ if (isImperial) {
18
+ const converter = WEIGHT_CONVERTERS[metricPrecision]?.[impPrec]
19
+ if (!converter) return `${fixedDecimals(value)} ${metricPrecision}`
20
+
21
+ const converted = converter.to(value)
22
+ if (withoutSuffix) return fixedDecimals(converted)
23
+ return `${fixedDecimals(converted)} ${impPrec}`
24
+ }
25
+
26
+ if (withoutSuffix) return fixedDecimals(value)
27
+ return `${fixedDecimals(value)} ${metricPrecision}`
28
+ }
29
+ }
@@ -0,0 +1,20 @@
1
+ import { Button } from '../actions'
2
+
3
+ // Plain ReactJS (Vite/Next/CRA): react-router goBack via navigate(-1). require() in try/catch so a
4
+ // web app without react-router-dom degrades gracefully instead of failing the module load.
5
+ let useNavigate
6
+ try {
7
+ useNavigate = require('react-router-dom').useNavigate
8
+ } catch {
9
+ useNavigate = () => () => console.warn('ReturnButton: react-router-dom not installed.')
10
+ }
11
+
12
+ // A back/close Button. Defaults to a left-arrow icon that calls navigate(-1). `close` swaps to a
13
+ // close icon; `icon` overrides the icon name; `onPress` overrides goBack. Extra props pass to the
14
+ // Button (label, outline, size, color, etc.).
15
+ export function ReturnButton({ icon, close, onPress, ...props }) {
16
+ const navigate = useNavigate()
17
+ const name = icon || (close ? 'close-line' : 'arrow-left-s-line')
18
+
19
+ return <Button icon={name} onPress={onPress || (() => navigate(-1))} {...props} />
20
+ }
@@ -0,0 +1,20 @@
1
+ import { Button } from '../actions'
2
+
3
+ // react-navigation goBack. require() in try/catch so a native app without react-navigation degrades
4
+ // gracefully instead of failing the module load.
5
+ let useNavigation
6
+ try {
7
+ useNavigation = require('@react-navigation/native').useNavigation
8
+ } catch {
9
+ useNavigation = () => ({ goBack: () => console.warn('ReturnButton: @react-navigation/native not installed.') })
10
+ }
11
+
12
+ // A back/close Button. Defaults to a left-arrow icon that calls navigation.goBack(). `close` swaps to
13
+ // a close icon; `icon` overrides the icon name; `onPress` overrides goBack. Extra props pass to the
14
+ // Button (label, outline, size, color, etc.).
15
+ export function ReturnButton({ icon, close, onPress, ...props }) {
16
+ const navigation = useNavigation()
17
+ const name = icon || (close ? 'close-line' : 'arrow-left-s-line')
18
+
19
+ return <Button icon={name} onPress={onPress || (() => navigation.goBack())} {...props} />
20
+ }
@@ -0,0 +1,2 @@
1
+ // react-native-web apps use react-navigation (like native), so reuse the native implementation.
2
+ export { ReturnButton } from './ReturnButton.native'
@@ -0,0 +1,25 @@
1
+ import { Icon } from '../presentation'
2
+ import { Link } from '../actions'
3
+
4
+ // Plain ReactJS (Vite/Next/CRA): react-router goBack via navigate(-1). require() in try/catch so a
5
+ // web app without react-router-dom degrades gracefully instead of failing the module load.
6
+ let useNavigate
7
+ try {
8
+ useNavigate = require('react-router-dom').useNavigate
9
+ } catch {
10
+ useNavigate = () => () => console.warn('ReturnLink: react-router-dom not installed.')
11
+ }
12
+
13
+ // A back/close Link: a Link wrapping an Icon. Defaults to a left-arrow that calls navigate(-1).
14
+ // `close` swaps to a close icon; `icon` overrides the icon name entirely; `onPress` overrides goBack.
15
+ // Extra props pass to the Icon (size, color, etc.).
16
+ export function ReturnLink({ icon, close, onPress, ...props }) {
17
+ const navigate = useNavigate()
18
+ const name = icon || (close ? 'close-line' : 'arrow-left-s-line')
19
+
20
+ return (
21
+ <Link onPress={onPress || (() => navigate(-1))}>
22
+ <Icon name={name} {...props} />
23
+ </Link>
24
+ )
25
+ }
@@ -0,0 +1,25 @@
1
+ import { Icon } from '../presentation'
2
+ import { Link } from '../actions'
3
+
4
+ // react-navigation goBack. require() in try/catch so a native app without react-navigation degrades
5
+ // gracefully instead of failing the module load.
6
+ let useNavigation
7
+ try {
8
+ useNavigation = require('@react-navigation/native').useNavigation
9
+ } catch {
10
+ useNavigation = () => ({ goBack: () => console.warn('ReturnLink: @react-navigation/native not installed.') })
11
+ }
12
+
13
+ // A back/close Link: a Link wrapping an Icon. Defaults to a left-arrow that calls navigation.goBack().
14
+ // `close` swaps to a close icon; `icon` overrides the icon name entirely; `onPress` overrides goBack.
15
+ // Extra props pass to the Icon (size, color, etc.).
16
+ export function ReturnLink({ icon, close, onPress, ...props }) {
17
+ const navigation = useNavigation()
18
+ const name = icon || (close ? 'close-line' : 'arrow-left-s-line')
19
+
20
+ return (
21
+ <Link onPress={onPress || (() => navigation.goBack())}>
22
+ <Icon name={name} {...props} />
23
+ </Link>
24
+ )
25
+ }
@@ -0,0 +1,2 @@
1
+ // react-native-web apps use react-navigation (like native), so reuse the native implementation.
2
+ export { ReturnLink } from './ReturnLink.native'
@@ -0,0 +1,21 @@
1
+ import { ActiveStepContent } from '../steps'
2
+ import { View } from '../structure'
3
+
4
+ // Plain ReactJS (Vite/Next/CRA) fallback — no react-navigation. Renders the active step in place,
5
+ // driven by StepsHandler's activeIndex, so a wizard built with these pieces still works on plain web
6
+ // (just without native stack transitions). React Native / RNW use the .native variant.
7
+ let warned = false
8
+
9
+ // eslint-disable-next-line no-unused-vars -- absorb the native-only screenOptions so it doesn't leak onto View
10
+ export function RoutedStepsContent({ screenOptions, ...props }) {
11
+ if (!warned) {
12
+ warned = true
13
+ console.warn('RoutedStepsContent is native-only (react-navigation). Rendering steps in place on plain web.')
14
+ }
15
+
16
+ return (
17
+ <View flex {...props}>
18
+ <ActiveStepContent />
19
+ </View>
20
+ )
21
+ }
@@ -0,0 +1,94 @@
1
+ import React from 'react'
2
+
3
+ import { View } from '../structure'
4
+ import { useSteps } from '../steps'
5
+
6
+ // Native-only deps. require() inside try/catch keeps web bundlers from pulling react-navigation into
7
+ // the plain ReactJS build (which uses RoutedStepsContent.js instead).
8
+ let NavigationContainer
9
+ let NavigationIndependentTree
10
+ let StackActions
11
+ let createNativeStackNavigator
12
+ let available = true
13
+ try {
14
+ const nav = require('@react-navigation/native')
15
+ NavigationContainer = nav.NavigationContainer
16
+ NavigationIndependentTree = nav.NavigationIndependentTree
17
+ StackActions = nav.StackActions
18
+ createNativeStackNavigator = require('@react-navigation/native-stack').createNativeStackNavigator
19
+ } catch {
20
+ available = false
21
+ }
22
+
23
+ let warned = false
24
+
25
+ // Renders a step's content exactly like ActiveStepContent — raw, no wrapper. Any scroll/padding is
26
+ // the consumer's call inside their own render/renderContent.
27
+ const stepContent = (item) => {
28
+ const Content = item.render || item.renderContent || item.Content
29
+ return Content ? <Content /> : null
30
+ }
31
+
32
+ // The swapping content region — the routed equivalent of ActiveStepContent. Drop it inside any
33
+ // StepsHandler, alongside whatever fixed chrome you want. It adds NO state of its own: StepsHandler
34
+ // stays the single source of truth. This component just renders a self-contained native stack
35
+ // (independent from the host NavigationContainer) and keeps it in sync with activeIndex both ways.
36
+ export function RoutedStepsContent({ screenOptions, ...props }) {
37
+ const { items, activeIndex, moveToIndex } = useSteps()
38
+ const navRef = React.useRef(null)
39
+ const Stack = React.useRef(available ? createNativeStackNavigator() : null).current
40
+
41
+ // StepsHandler -> stack: when activeIndex changes (Next/Back buttons, StepsMenu, custom controls),
42
+ // drive the stack to the matching screen. Compare against the current TOP route (getCurrentRoute)
43
+ // and be explicit about direction: push forward, pop back. Using navigate() here is wrong — it can
44
+ // push a duplicate instead of popping. If the route already matches (e.g. a native gesture back
45
+ // already moved us), this is a no-op.
46
+ React.useEffect(() => {
47
+ const ref = navRef.current
48
+ if (!ref?.isReady?.()) return
49
+ const current = Number(ref.getCurrentRoute?.()?.name) || 0
50
+ if (activeIndex === current) return
51
+ ref.dispatch(
52
+ activeIndex > current ? StackActions.push(String(activeIndex)) : StackActions.popTo(String(activeIndex))
53
+ )
54
+ }, [activeIndex])
55
+
56
+ if (!available) {
57
+ if (!warned) {
58
+ warned = true
59
+ console.warn(
60
+ 'RoutedStepsContent requires @react-navigation/native and @react-navigation/native-stack; neither is installed.'
61
+ )
62
+ }
63
+ return null
64
+ }
65
+
66
+ // stack -> StepsHandler: native gesture / hardware back changes the route without going through
67
+ // moveToIndex. Push that index back into StepsHandler (backward never validates, matching Steps).
68
+ const handleStateChange = () => {
69
+ const index = Number(navRef.current?.getCurrentRoute?.()?.name) || 0
70
+ if (index !== activeIndex) moveToIndex(index)
71
+ }
72
+
73
+ const navigator = (
74
+ <Stack.Navigator screenOptions={{ headerShown: false, ...screenOptions }}>
75
+ {items.map((item, index) => (
76
+ <Stack.Screen key={index} name={String(index)} options={{ title: item.label }}>
77
+ {() => stepContent(item)}
78
+ </Stack.Screen>
79
+ ))}
80
+ </Stack.Navigator>
81
+ )
82
+
83
+ // Self-contained: its own navigation world, independent of the host app's NavigationContainer, so
84
+ // it never touches the host back stack (react-navigation v7+).
85
+ return (
86
+ <View flex {...props}>
87
+ <NavigationIndependentTree>
88
+ <NavigationContainer ref={navRef} onStateChange={handleStateChange}>
89
+ {navigator}
90
+ </NavigationContainer>
91
+ </NavigationIndependentTree>
92
+ </View>
93
+ )
94
+ }
@@ -0,0 +1,3 @@
1
+ // react-native-web: react-navigation runs fine here, and babel aliases react-native ->
2
+ // react-native-web, so the native implementation is correct for RNW too.
3
+ export { RoutedStepsContent } from './RoutedStepsContent.native'
@@ -0,0 +1,3 @@
1
+ export * from './RoutedStepsContent'
2
+ export * from './ReturnLink'
3
+ export * from './ReturnButton'
@@ -24,7 +24,7 @@ export function StatePresenter({
24
24
  if (error) {
25
25
  return (
26
26
  <View flex center {...props}>
27
- <Result type="error" title={errorTitle} description={errorDescription || error.message} />
27
+ <Result type="error" title={errorTitle} description={errorDescription || (typeof error === 'string' ? error : error.message)} />
28
28
  </View>
29
29
  )
30
30
  }
@@ -8,6 +8,7 @@ export function StepsHandler({ children, items, onSubmit, onValidateStep, onStep
8
8
  const [maxIndexReleased, setMaxIndexReleased] = React.useState(0)
9
9
  const [loading, setLoading] = React.useState(false)
10
10
  const activeStep = items[activeIndex]
11
+ const isFirstStep = activeIndex === 0
11
12
  const isLastStep = activeIndex === items.length - 1
12
13
 
13
14
  const moveToIndex = async (index) => {
@@ -44,6 +45,7 @@ export function StepsHandler({ children, items, onSubmit, onValidateStep, onStep
44
45
  activeStep,
45
46
  maxIndexReleased,
46
47
  loading,
48
+ isFirstStep,
47
49
  isLastStep,
48
50
  moveToNextStep,
49
51
  moveToPrevStep,
@@ -4,14 +4,24 @@ import { Text } from '../text'
4
4
  import { View } from './View'
5
5
  import { useDefaultModifier } from '../../modifiers/default'
6
6
  import { useResponsiveConverter } from '../../modifiers/responsiveConverter'
7
- import { useThemeComponentModifier } from '../../modifiers/themeComponent'
8
7
  import { useSafeAreaInsets } from '../../abstractions/helpers/useSafeAreaInsets'
8
+ import { useThemeComponentModifier } from '../../modifiers/themeComponent'
9
9
 
10
10
  const DEFAULT_PROPS = {
11
11
  borderB: 'overlayDivider',
12
+ titleProps: {
13
+ center: true,
14
+ size: 'h6',
15
+ numberOfLines: 1,
16
+ },
17
+ subtitleProps: {
18
+ center: true,
19
+ size: 'xs',
20
+ numberOfLines: 1,
21
+ },
12
22
  }
13
23
 
14
- export function TopBar({ right, left, WrapperView, children, ...rootProps }) {
24
+ export function TopBar({ title, subtitle, right, left, WrapperView, children, ...rootProps }) {
15
25
  const { top: safeTop } = useSafeAreaInsets()
16
26
 
17
27
  const [_, props] = pipe(
@@ -19,7 +29,7 @@ export function TopBar({ right, left, WrapperView, children, ...rootProps }) {
19
29
  useDefaultModifier(DEFAULT_PROPS),
20
30
  useResponsiveConverter([])
21
31
  )([{}, rootProps])
22
- let { useSafeArea = true, title, subtitle } = props
32
+ let { useSafeArea = true, titleProps, subtitleProps } = props
23
33
 
24
34
  const hasContent = !!title || !!subtitle || !!children || !!right || !!left
25
35
 
@@ -34,17 +44,9 @@ export function TopBar({ right, left, WrapperView, children, ...rootProps }) {
34
44
  </View>
35
45
 
36
46
  <View center flex={3}>
37
- {children || (
38
- <Text center h6 numberOfLines={1}>
39
- {title}
40
- </Text>
41
- )}
42
-
43
- {subtitle && (
44
- <Text center xs numberOfLines={1}>
45
- {subtitle}
46
- </Text>
47
- )}
47
+ {children || <Text {...titleProps}>{title}</Text>}
48
+
49
+ {subtitle && <Text {...subtitleProps}>{subtitle}</Text>}
48
50
  </View>
49
51
 
50
52
  <View flex={1} toRight>
@@ -3,7 +3,7 @@ import { ThemePicker } from './ThemePicker'
3
3
 
4
4
  export function ThemePickerDrawer({ open, onClose, onChange }) {
5
5
  return (
6
- <BottomDrawer open={open} onClose={onClose} maxWidth={550} snapPoints={['50%', '85%']}>
6
+ <BottomDrawer open={open} onClose={onClose} maxWidth={550} snapPoints={['60%', '85%']} useSafeArea={false}>
7
7
  <DrawerScrollView padding="md">
8
8
  <ThemePicker onChange={onChange} />
9
9
  </DrawerScrollView>
@@ -0,0 +1,61 @@
1
+ const IMAGE_DEFAULTS = { maxWidth: 1920, maxHeight: 1920, quality: 0.8 }
2
+
3
+ function isImage(asset) {
4
+ return asset?.type?.startsWith('image/')
5
+ }
6
+
7
+ function loadImage(src) {
8
+ return new Promise((resolve, reject) => {
9
+ const img = new Image()
10
+ img.onload = () => resolve(img)
11
+ img.onerror = reject
12
+ img.src = src
13
+ })
14
+ }
15
+
16
+ const ALPHA_TYPES = new Set(['image/png', 'image/webp', 'image/gif'])
17
+
18
+ function compressWithCanvas(img, { maxWidth, maxHeight, quality, mimeType }) {
19
+ let { width, height } = img
20
+ if (width > maxWidth || height > maxHeight) {
21
+ const ratio = Math.min(maxWidth / width, maxHeight / height)
22
+ width = Math.round(width * ratio)
23
+ height = Math.round(height * ratio)
24
+ }
25
+
26
+ const canvas = document.createElement('canvas')
27
+ canvas.width = width
28
+ canvas.height = height
29
+ const ctx = canvas.getContext('2d')
30
+ ctx.drawImage(img, 0, 0, width, height)
31
+
32
+ const outputType = ALPHA_TYPES.has(mimeType) ? mimeType : 'image/jpeg'
33
+
34
+ return new Promise((resolve) => {
35
+ canvas.toBlob(
36
+ (blob) => resolve(blob ? { uri: URL.createObjectURL(blob), width, height } : null),
37
+ outputType,
38
+ quality
39
+ )
40
+ })
41
+ }
42
+
43
+ export async function compressAsset(asset, options = {}) {
44
+ if (!asset?.uri || !isImage(asset)) return asset
45
+
46
+ try {
47
+ const opts = { ...IMAGE_DEFAULTS, ...options.image }
48
+ const img = await loadImage(asset.uri)
49
+ const result = await compressWithCanvas(img, { ...opts, mimeType: asset.type })
50
+ if (!result) return asset
51
+ return { ...asset, uri: result.uri, width: result.width, height: result.height }
52
+ } catch (e) {
53
+ console.warn('[neko-ui compress] web image compression failed:', e?.message)
54
+ return asset
55
+ }
56
+ }
57
+
58
+ export function compressAssets(assets, options = {}) {
59
+ if (!assets?.length) return Promise.resolve(assets || [])
60
+ return Promise.all(assets.map((a) => compressAsset(a, options)))
61
+ }
@@ -0,0 +1,49 @@
1
+ let ImageCompressor
2
+ let VideoCompressor
3
+ try {
4
+ const RNC = require('react-native-compressor')
5
+ ImageCompressor = RNC.Image
6
+ VideoCompressor = RNC.Video
7
+ } catch {}
8
+
9
+ const IMAGE_DEFAULTS = { maxWidth: 1920, maxHeight: 1920, quality: 0.8 }
10
+ const VIDEO_DEFAULTS = { maxSize: 720 }
11
+
12
+ function isImage(asset) {
13
+ return asset?.type?.startsWith('image/')
14
+ }
15
+
16
+ function isVideo(asset) {
17
+ return asset?.type?.startsWith('video/')
18
+ }
19
+
20
+ export async function compressAsset(asset, options = {}) {
21
+ if (!asset?.uri) return asset
22
+
23
+ if (isImage(asset) && ImageCompressor) {
24
+ try {
25
+ const uri = await ImageCompressor.compress(asset.uri, { ...IMAGE_DEFAULTS, ...options.image })
26
+ return { ...asset, uri }
27
+ } catch (e) {
28
+ console.warn('[neko-ui compress] image failed, keeping original:', e?.message)
29
+ return asset
30
+ }
31
+ }
32
+
33
+ if (isVideo(asset) && VideoCompressor) {
34
+ try {
35
+ const uri = await VideoCompressor.compress(asset.uri, { ...VIDEO_DEFAULTS, ...options.video })
36
+ return { ...asset, uri }
37
+ } catch (e) {
38
+ console.warn('[neko-ui compress] video failed, keeping original:', e?.message)
39
+ return asset
40
+ }
41
+ }
42
+
43
+ return asset
44
+ }
45
+
46
+ export function compressAssets(assets, options = {}) {
47
+ if (!assets?.length) return Promise.resolve(assets || [])
48
+ return Promise.all(assets.map((a) => compressAsset(a, options)))
49
+ }
@@ -0,0 +1,7 @@
1
+ // Web / non-native: no filesystem persistence. The uri (blob/object URL or
2
+ // remote) is used as-is. Mirrors the native API surface.
3
+ export function persistFile(uri) {
4
+ return uri
5
+ }
6
+
7
+ export function removeFile() {}
@@ -0,0 +1,55 @@
1
+ let FS
2
+ try {
3
+ FS = require('expo-file-system')
4
+ } catch {}
5
+
6
+ // `target="<base>/<subdir>"`, base is 'document' | 'cache'. Resolves to a
7
+ // permanent Directory (created if missing) via the modern sync expo-file-system API.
8
+ function resolveDir(target) {
9
+ const [base, ...rest] = (target || '').split('/').filter(Boolean)
10
+ if (base !== 'cache' && base !== 'document') {
11
+ console.warn(`[neko-ui files] target base "${base}" unknown — using document dir. Use "document" or "cache".`)
12
+ }
13
+ const root = base === 'cache' ? FS.Paths.cache : FS.Paths.document
14
+ const dir = new FS.Directory(root, ...rest)
15
+ try {
16
+ dir.create({ intermediates: true, idempotent: true })
17
+ } catch (e) {
18
+ console.warn('[neko-ui files] dir create failed:', e?.message)
19
+ }
20
+ return dir
21
+ }
22
+
23
+ let seq = 0
24
+
25
+ // Copy a file at `uri` into a permanent app directory (`target`, default
26
+ // 'document/files'). Returns the durable uri string. Never throws — on failure
27
+ // or when expo-file-system is unavailable, returns the original uri.
28
+ // `opts.name` is used only to derive the file extension (falls back to the uri,
29
+ // then 'jpg'); the stored filename is always unique (timestamp + counter), so
30
+ // rapid multi-select can't collide. Expects a `file://` source (e.g.
31
+ // expo-image-picker output); `content://` sources can't be copied and fall back
32
+ // to the original uri.
33
+ export function persistFile(uri, target = 'document/files', { name } = {}) {
34
+ if (!FS || !uri) return uri
35
+ try {
36
+ const ext = name?.split('.').pop() || uri.split('?')[0].split('.').pop() || 'jpg'
37
+ const filename = `${Date.now()}_${seq++}_${Math.round(Math.random() * 1e6)}.${ext}`
38
+ const dest = new FS.File(resolveDir(target), filename)
39
+ new FS.File(uri).copy(dest)
40
+ return dest.uri
41
+ } catch (e) {
42
+ console.warn('[neko-ui files] persistFile failed, keeping uri:', e?.message)
43
+ return uri
44
+ }
45
+ }
46
+
47
+ // Delete a persisted file. No-op if missing / unavailable.
48
+ export function removeFile(uri) {
49
+ if (!FS || !uri) return
50
+ try {
51
+ new FS.File(uri).delete()
52
+ } catch (e) {
53
+ console.warn('[neko-ui files] removeFile failed:', e?.message)
54
+ }
55
+ }
@@ -1,7 +1,12 @@
1
+ export * from './compress'
1
2
  export * from './debounce'
2
- export * from './string'
3
+ export * from './files'
4
+ export * from './media'
5
+ export * from './numbers'
6
+ export * from './pickAssets'
3
7
  export * from './random'
4
8
  export * from './storage'
9
+ export * from './string'
5
10
  export * from './weekStart'
6
11
  export * from './weekStartSetup'
7
12
  export * from './../abstractions/helpers/useSafeAreaInsets'
@@ -0,0 +1,4 @@
1
+ // Web / non-native: native camera & library pickers aren't available here.
2
+ // (Upload's web implementation uses its own file-input flow.)
3
+ export const openCamera = async () => []
4
+ export const openLibrary = async () => []
@@ -0,0 +1,41 @@
1
+ let ImagePicker
2
+ try {
3
+ ImagePicker = require('expo-image-picker')
4
+ } catch {}
5
+
6
+ // Normalize an expo-image-picker asset into the shape neko-ui works with.
7
+ // Internal — callers consume the already-normalized assets from openCamera/openLibrary.
8
+ function normalizeImageResult(asset) {
9
+ return {
10
+ uri: asset.uri,
11
+ name: asset.fileName || asset.uri.split('/').pop(),
12
+ type: asset.mimeType || asset.type || 'image/jpeg',
13
+ size: asset.fileSize || asset.filesize,
14
+ width: asset.width,
15
+ height: asset.height,
16
+ }
17
+ }
18
+
19
+ // Request camera permission, launch the camera, return normalized assets.
20
+ // Returns [] when expo-image-picker is missing, permission is denied, or the user
21
+ // cancels. The caller owns post-processing (persist, save-to-library, closing any
22
+ // drawer) — and any drawer should stay open until this resolves (iOS present/
23
+ // dismiss collision otherwise).
24
+ export async function openCamera(options = {}) {
25
+ if (!ImagePicker) return []
26
+ const permission = await ImagePicker.requestCameraPermissionsAsync()
27
+ if (!permission.granted) return []
28
+ const result = await ImagePicker.launchCameraAsync(options)
29
+ if (result.canceled) return []
30
+ return result.assets.map(normalizeImageResult)
31
+ }
32
+
33
+ // Same as openCamera but for the photo library.
34
+ export async function openLibrary(options = {}) {
35
+ if (!ImagePicker) return []
36
+ const permission = await ImagePicker.requestMediaLibraryPermissionsAsync()
37
+ if (!permission.granted) return []
38
+ const result = await ImagePicker.launchImageLibraryAsync(options)
39
+ if (result.canceled) return []
40
+ return result.assets.map(normalizeImageResult)
41
+ }
@@ -0,0 +1,13 @@
1
+ import { is } from 'ramda'
2
+
3
+ export function fixedDecimals(num, count = 2) {
4
+ if (!num) return num
5
+ if (Number.isInteger(num)) return num
6
+ if (is(String, num)) num = parseFloat(num)
7
+
8
+ const decimalPart = num.toString().split('.')[1]
9
+ if (decimalPart && decimalPart.length > count) {
10
+ return parseFloat(num.toFixed(count))
11
+ }
12
+ return num
13
+ }
@@ -0,0 +1,7 @@
1
+ export async function pickFromCamera({ multiple = false } = {}) {
2
+ return multiple ? [] : null
3
+ }
4
+
5
+ export async function pickFromLibrary({ multiple = false } = {}) {
6
+ return multiple ? [] : null
7
+ }