@alia.onl/sdk 2.0.0 → 3.0.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.
@@ -0,0 +1,276 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import {
3
+ View,
4
+ Pressable,
5
+ type TextInput as RNTextInput,
6
+ } from "react-native";
7
+ import { KeyboardAvoidingView } from "../../../lib/keyboard";
8
+ import { Maximize2, Minimize2 } from "lucide-react-native";
9
+ import { cn } from "../../../lib/utils";
10
+ import { PromptInputContext, type Attachment, type Completion } from "./context";
11
+ import { PromptInputTextarea } from "./textarea";
12
+ import { PromptInputActions } from "./actions";
13
+ import { PromptInputMicButton } from "./mic-button";
14
+ import { PromptInputAutocomplete } from "./autocomplete";
15
+ import { PromptInputAttachments } from "./attachments";
16
+ import { PromptInputSubmitButton } from "./submit-button";
17
+ import { PromptInputAddMenu } from "./add-menu";
18
+
19
+ export type PromptInputProps = {
20
+ isLoading?: boolean;
21
+ value?: string;
22
+ onValueChange?: (value: string) => void;
23
+ maxHeight?: number;
24
+ onSubmit?: () => void;
25
+ children?: React.ReactNode;
26
+ className?: string;
27
+ disabled?: boolean;
28
+ onImagePaste?: (files: File[]) => void;
29
+ // Simple mode props (when no children)
30
+ placeholder?: string;
31
+ autocomplete?: boolean;
32
+ autocompletePosition?: "top" | "bottom";
33
+ // Shows the add menu as a standalone button to the left of the input box
34
+ leadingAddMenu?: boolean;
35
+ // Custom left-side actions (replaces default add menu in the actions bar)
36
+ actionsLeft?: React.ReactNode;
37
+ // Submit button props
38
+ onStop?: () => void;
39
+ emptyAction?: React.ReactNode;
40
+ // Controlled attachments (optional — uses internal state if omitted)
41
+ attachments?: Attachment[];
42
+ onAddAttachment?: (attachment: Attachment) => void;
43
+ onRemoveAttachment?: (id: string) => void;
44
+ onUpdateAttachment?: (id: string, updates: Partial<Attachment>) => void;
45
+ // Injectable autocomplete hooks
46
+ useSuggestions?: (query: string) => { data: Completion[] | undefined; isLoading: boolean };
47
+ onSuggestionUsed?: (suggestionId: string) => void;
48
+ // Injectable error handler (replaces toast)
49
+ onError?: (message: string) => void;
50
+ // Mic button API URL
51
+ apiUrl?: string;
52
+ } & Omit<React.ComponentProps<typeof View>, "children">;
53
+
54
+ export function PromptInput({
55
+ className,
56
+ isLoading = false,
57
+ maxHeight = 240,
58
+ value,
59
+ onValueChange,
60
+ onSubmit,
61
+ children,
62
+ disabled = false,
63
+ onImagePaste,
64
+ placeholder,
65
+ autocomplete = false,
66
+ autocompletePosition = "top",
67
+ leadingAddMenu = false,
68
+ actionsLeft,
69
+ onStop,
70
+ emptyAction,
71
+ attachments: controlledAttachments,
72
+ onAddAttachment,
73
+ onRemoveAttachment,
74
+ onUpdateAttachment,
75
+ useSuggestions,
76
+ onSuggestionUsed,
77
+ onError,
78
+ apiUrl,
79
+ ...props
80
+ }: PromptInputProps) {
81
+ const [internalValue, setInternalValue] = useState(value || "");
82
+ const [currentHeight, setCurrentHeight] = useState(44);
83
+ const [showFullscreen, setShowFullscreen] = useState(false);
84
+ const [handleCompletionKey, setHandleCompletionKey] = useState<((key: string) => boolean) | null>(null);
85
+ const textareaRef = useRef<RNTextInput>(null);
86
+
87
+ // Internal attachment state (used when no controlled props)
88
+ const [internalAttachments, setInternalAttachments] = useState<Attachment[]>(
89
+ []
90
+ );
91
+ const attachments = controlledAttachments ?? internalAttachments;
92
+
93
+ const addAttachment = useCallback(
94
+ (a: Attachment) => {
95
+ if (onAddAttachment) {
96
+ onAddAttachment(a);
97
+ } else {
98
+ setInternalAttachments((prev) => [...prev, a]);
99
+ }
100
+ },
101
+ [onAddAttachment]
102
+ );
103
+
104
+ const removeAttachment = useCallback(
105
+ (id: string) => {
106
+ if (onRemoveAttachment) {
107
+ onRemoveAttachment(id);
108
+ } else {
109
+ setInternalAttachments((prev) => prev.filter((a) => a.id !== id));
110
+ }
111
+ },
112
+ [onRemoveAttachment]
113
+ );
114
+
115
+ const updateAttachment = useCallback(
116
+ (id: string, updates: Partial<Attachment>) => {
117
+ if (onUpdateAttachment) {
118
+ onUpdateAttachment(id, updates);
119
+ } else {
120
+ setInternalAttachments((prev) =>
121
+ prev.map((a) => (a.id === id ? { ...a, ...updates } : a))
122
+ );
123
+ }
124
+ },
125
+ [onUpdateAttachment]
126
+ );
127
+
128
+ const handleChange = (newValue: string) => {
129
+ setInternalValue(newValue);
130
+ onValueChange?.(newValue);
131
+ };
132
+
133
+ const handleSubmit = () => {
134
+ onSubmit?.();
135
+ if (showFullscreen) setShowFullscreen(false);
136
+ };
137
+
138
+ useEffect(() => {
139
+ if (!showFullscreen) setCurrentHeight(44);
140
+ }, [showFullscreen]);
141
+
142
+ const showExpandIcon = currentHeight > 100;
143
+ const isSimpleMode = !children;
144
+
145
+ const contextValue = {
146
+ isLoading,
147
+ value: value ?? internalValue,
148
+ setValue: onValueChange ?? handleChange,
149
+ maxHeight,
150
+ onSubmit: handleSubmit,
151
+ disabled,
152
+ textareaRef,
153
+ currentHeight,
154
+ setCurrentHeight,
155
+ isFullscreen: showFullscreen,
156
+ onImagePaste,
157
+ attachments,
158
+ addAttachment,
159
+ removeAttachment,
160
+ updateAttachment,
161
+ handleCompletionKey,
162
+ setHandleCompletionKey,
163
+ useSuggestions,
164
+ onSuggestionUsed,
165
+ onError,
166
+ };
167
+
168
+ const content = isSimpleMode ? (
169
+ <>
170
+ <PromptInputAttachments />
171
+ <PromptInputTextarea
172
+ placeholder={placeholder}
173
+ className="min-h-[44px] text-base py-3"
174
+ />
175
+ <PromptInputActions className="flex-row items-center justify-between gap-2 mt-2 mb-1 px-3">
176
+ <View className="flex-row items-center gap-1.5">
177
+ {actionsLeft ?? <PromptInputAddMenu />}
178
+ </View>
179
+ <View className="flex-row items-center gap-1.5">
180
+ <PromptInputMicButton apiUrl={apiUrl} />
181
+ <PromptInputSubmitButton
182
+ isLoading={isLoading}
183
+ onStop={onStop}
184
+ emptyAction={emptyAction}
185
+ />
186
+ </View>
187
+ </PromptInputActions>
188
+ </>
189
+ ) : (
190
+ children
191
+ );
192
+
193
+ const inputBox = (
194
+ <Pressable
195
+ onPress={() => {
196
+ if (!disabled) textareaRef.current?.focus();
197
+ }}
198
+ disabled={disabled}
199
+ >
200
+ <View
201
+ className={cn(
202
+ "rounded-[24px] border border-border bg-background relative overflow-hidden",
203
+ disabled && "opacity-60",
204
+ className
205
+ )}
206
+ {...props}
207
+ >
208
+ {showExpandIcon && !disabled && (
209
+ <Pressable
210
+ onPress={() => setShowFullscreen(true)}
211
+ className="absolute top-2 right-2 z-10 bg-background rounded-full p-1.5 border border-border active:opacity-70"
212
+ >
213
+ <Maximize2 size={16} className="text-muted-foreground" />
214
+ </Pressable>
215
+ )}
216
+ {content}
217
+ </View>
218
+ </Pressable>
219
+ );
220
+
221
+ return (
222
+ <PromptInputContext.Provider value={contextValue}>
223
+ {autocomplete && autocompletePosition === "top" && !leadingAddMenu && (
224
+ <PromptInputAutocomplete position="top" />
225
+ )}
226
+
227
+ <KeyboardAvoidingView behavior="padding">
228
+ {leadingAddMenu ? (
229
+ <View className="flex-row items-end gap-2">
230
+ <PromptInputAddMenu
231
+ iconSize={20}
232
+ className="h-10 w-10 rounded-full border"
233
+ />
234
+ <View className="flex-1">
235
+ {autocomplete && autocompletePosition === "top" && (
236
+ <PromptInputAutocomplete position="top" />
237
+ )}
238
+ {inputBox}
239
+ {autocomplete && autocompletePosition === "bottom" && (
240
+ <PromptInputAutocomplete position="bottom" />
241
+ )}
242
+ </View>
243
+ </View>
244
+ ) : (
245
+ inputBox
246
+ )}
247
+ </KeyboardAvoidingView>
248
+
249
+ {autocomplete && autocompletePosition === "bottom" && !leadingAddMenu && (
250
+ <PromptInputAutocomplete position="bottom" />
251
+ )}
252
+
253
+ {showFullscreen && (
254
+ <View
255
+ style={{
256
+ position: "fixed" as any,
257
+ top: 0,
258
+ left: 0,
259
+ right: 0,
260
+ bottom: 0,
261
+ zIndex: 9998,
262
+ }}
263
+ className="bg-background"
264
+ >
265
+ <Pressable
266
+ onPress={() => setShowFullscreen(false)}
267
+ className="absolute top-4 right-4 z-50 p-2 active:opacity-70 bg-background/80 rounded-full"
268
+ >
269
+ <Minimize2 size={20} className="text-foreground" />
270
+ </Pressable>
271
+ <View className="flex-1 flex-col">{content}</View>
272
+ </View>
273
+ )}
274
+ </PromptInputContext.Provider>
275
+ );
276
+ }
@@ -0,0 +1,49 @@
1
+ import React from "react";
2
+ import { ArrowUp, Square } from "lucide-react-native";
3
+ import { cn } from "../../../lib/utils";
4
+ import { Button } from "../button";
5
+ import { usePromptInput } from "./context";
6
+
7
+ export type PromptInputSubmitButtonProps = {
8
+ isLoading?: boolean;
9
+ onStop?: () => void;
10
+ emptyAction?: React.ReactNode;
11
+ className?: string;
12
+ };
13
+
14
+ export function PromptInputSubmitButton({
15
+ isLoading,
16
+ onStop,
17
+ emptyAction,
18
+ className,
19
+ }: PromptInputSubmitButtonProps) {
20
+ const { onSubmit, value, attachments } = usePromptInput();
21
+ const hasContent = value.trim() || attachments.length > 0;
22
+
23
+ if (isLoading && onStop) {
24
+ return (
25
+ <Button
26
+ size="icon"
27
+ onPress={onStop}
28
+ className={cn("h-8 w-8 rounded-full", className)}
29
+ >
30
+ <Square size={12} color="white" className="fill-current" />
31
+ </Button>
32
+ );
33
+ }
34
+
35
+ if (!hasContent && emptyAction) {
36
+ return <>{emptyAction}</>;
37
+ }
38
+
39
+ return (
40
+ <Button
41
+ size="icon"
42
+ onPress={onSubmit}
43
+ disabled={!hasContent}
44
+ className={cn("h-8 w-8 rounded-full", className)}
45
+ >
46
+ <ArrowUp size={16} color="white" />
47
+ </Button>
48
+ );
49
+ }
@@ -0,0 +1,64 @@
1
+ import React from "react";
2
+ import { View } from "react-native";
3
+ import { cn } from "../../../lib/utils";
4
+ import { ChatTextInput } from "../chat-text-input";
5
+ import { usePromptInput } from "./context";
6
+
7
+ export type PromptInputTextareaProps = {
8
+ placeholder?: string;
9
+ className?: string;
10
+ } & React.ComponentProps<typeof ChatTextInput>;
11
+
12
+ export function PromptInputTextarea({
13
+ className,
14
+ placeholder,
15
+ style,
16
+ ...props
17
+ }: PromptInputTextareaProps) {
18
+ const {
19
+ value,
20
+ setValue,
21
+ onSubmit,
22
+ disabled,
23
+ textareaRef,
24
+ setCurrentHeight,
25
+ isFullscreen,
26
+ maxHeight,
27
+ onImagePaste,
28
+ handleCompletionKey,
29
+ } = usePromptInput();
30
+
31
+ const textInput = (
32
+ <ChatTextInput
33
+ ref={textareaRef}
34
+ value={value}
35
+ onChangeText={setValue}
36
+ onSubmitEditing={onSubmit}
37
+ onEnterPress={onSubmit}
38
+ onHeightChange={setCurrentHeight}
39
+ onCompletionKey={handleCompletionKey ?? undefined}
40
+ disableEnterToSubmit={isFullscreen}
41
+ disableAutoHeight={isFullscreen}
42
+ maxHeight={isFullscreen ? 10000 : maxHeight}
43
+ onImagePaste={onImagePaste}
44
+ fillContainer={isFullscreen}
45
+ className={cn(
46
+ "w-full border-0 bg-transparent text-foreground shadow-none",
47
+ isFullscreen ? "px-4 pt-4" : "min-h-[44px] py-3",
48
+ className
49
+ )}
50
+ style={[style, isFullscreen && { paddingBottom: 100 }]}
51
+ placeholder={placeholder}
52
+ multiline
53
+ editable={!disabled}
54
+ noFocus={true}
55
+ {...props}
56
+ />
57
+ );
58
+
59
+ if (isFullscreen) {
60
+ return <View style={{ flex: 1 }}>{textInput}</View>;
61
+ }
62
+
63
+ return textInput;
64
+ }
@@ -0,0 +1,29 @@
1
+ import * as React from "react";
2
+ import { Text as RNText, type TextProps as RNTextProps } from "react-native";
3
+ import { cn } from "../../lib/utils";
4
+
5
+ const TextClassContext = React.createContext<string | undefined>(undefined);
6
+
7
+ export type TextProps = RNTextProps & {
8
+ className?: string;
9
+ };
10
+
11
+ const Text = React.forwardRef<RNText, TextProps>(
12
+ ({ className, ...props }, ref) => {
13
+ const textClass = React.useContext(TextClassContext);
14
+ return (
15
+ <RNText
16
+ className={cn(
17
+ "text-base leading-7 text-foreground web:select-text font-sans",
18
+ textClass,
19
+ className
20
+ )}
21
+ ref={ref}
22
+ {...props}
23
+ />
24
+ );
25
+ }
26
+ );
27
+ Text.displayName = "Text";
28
+
29
+ export { Text, TextClassContext };
@@ -1,4 +1,4 @@
1
- import { useState, useRef, useCallback, useEffect } from 'react';
1
+ import React, { useState, useRef, useCallback, useEffect } from 'react';
2
2
  import { useOxy } from '@oxyhq/services';
3
3
  import type { ChatMessage, ToolInvocation } from '../types';
4
4
 
@@ -17,8 +17,10 @@ export interface UseAliaChatOptions {
17
17
 
18
18
  export interface UseAliaChatReturn {
19
19
  messages: ChatMessage[];
20
+ setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
20
21
  send: (text: string) => void;
21
22
  isStreaming: boolean;
23
+ stop: () => void;
22
24
  clear: () => void;
23
25
  error: string | null;
24
26
  }
@@ -312,8 +314,6 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
312
314
  updateAssistant(() => ({
313
315
  pendingPlan: {
314
316
  planId: parsed.planId,
315
- intent: parsed.intent,
316
- confidence: parsed.confidence,
317
317
  steps: parsed.steps || [],
318
318
  approved: false,
319
319
  rejected: false,
@@ -431,18 +431,27 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
431
431
  [isStreaming, getToken, apiUrl, model, clientContext, scheduleFlush, flushPendingUpdates, updateAssistant, applyToolResult],
432
432
  );
433
433
 
434
- const clear = useCallback(() => {
434
+ const stop = useCallback(() => {
435
435
  if (abortRef.current) {
436
436
  abortRef.current.abort();
437
437
  abortRef.current = null;
438
438
  }
439
- setMessages([]);
439
+ flushPendingUpdates();
440
+ if (flushTimerRef.current) {
441
+ clearTimeout(flushTimerRef.current);
442
+ flushTimerRef.current = null;
443
+ }
440
444
  setIsStreaming(false);
445
+ }, [flushPendingUpdates]);
446
+
447
+ const clear = useCallback(() => {
448
+ stop();
449
+ setMessages([]);
441
450
  setError(null);
442
451
  pendingContentRef.current = '';
443
452
  pendingReasoningRef.current = '';
444
453
  toolInvocationsRef.current = [];
445
- }, []);
454
+ }, [stop]);
446
455
 
447
- return { messages, send, isStreaming, clear, error };
456
+ return { messages, setMessages, send, isStreaming, stop, clear, error };
448
457
  }
@@ -0,0 +1,37 @@
1
+ import * as DocumentPicker from 'expo-document-picker';
2
+
3
+ export type DocumentPickerResult = {
4
+ uri: string;
5
+ name: string;
6
+ size: number;
7
+ mimeType: string;
8
+ };
9
+
10
+ type UseDocumentPickerResult = {
11
+ pickDocument: () => Promise<DocumentPickerResult[] | undefined>;
12
+ };
13
+
14
+ export function useDocumentPicker(): UseDocumentPickerResult {
15
+ const pickDocument = async (): Promise<DocumentPickerResult[] | undefined> => {
16
+ try {
17
+ const result = await DocumentPicker.getDocumentAsync({
18
+ type: '*/*',
19
+ multiple: true,
20
+ copyToCacheDirectory: true,
21
+ });
22
+
23
+ if (result.canceled === false && result.assets && result.assets.length > 0) {
24
+ return result.assets.map((asset: any) => ({
25
+ uri: asset.uri,
26
+ name: asset.name,
27
+ size: asset.size || 0,
28
+ mimeType: asset.mimeType || 'application/octet-stream',
29
+ }));
30
+ }
31
+ } catch (error) {
32
+ console.warn('Failed to pick document:', error);
33
+ }
34
+ };
35
+
36
+ return { pickDocument };
37
+ }
@@ -0,0 +1,38 @@
1
+ import * as ImagePicker from 'expo-image-picker';
2
+
3
+ export type ImagePickerAsset = {
4
+ uri: string;
5
+ name: string;
6
+ size: number;
7
+ mimeType: string;
8
+ };
9
+
10
+ type ImagePickerResult = {
11
+ pickImage: () => Promise<ImagePickerAsset[] | undefined>;
12
+ };
13
+
14
+ export function useImagePicker(): ImagePickerResult {
15
+ const pickImage = async (): Promise<ImagePickerAsset[] | undefined> => {
16
+ try {
17
+ const result = await ImagePicker.launchImageLibraryAsync({
18
+ allowsMultipleSelection: true,
19
+ mediaTypes: ImagePicker.MediaTypeOptions.Images,
20
+ aspect: [4, 3],
21
+ quality: 1,
22
+ });
23
+
24
+ if (!result.canceled && result.assets.length > 0) {
25
+ return result.assets.map((asset: any) => ({
26
+ uri: asset.uri,
27
+ name: asset.fileName || `image-${Date.now()}.jpg`,
28
+ size: asset.fileSize || 0,
29
+ mimeType: asset.mimeType || 'image/jpeg',
30
+ }));
31
+ }
32
+ } catch (error) {
33
+ console.warn('Failed to pick image:', error);
34
+ }
35
+ };
36
+
37
+ return { pickImage };
38
+ }
@@ -27,7 +27,7 @@ interface STTStoreState {
27
27
  setMetering: (v: number) => void;
28
28
  }
29
29
 
30
- const useSTTStore = create<STTStoreState>((set) => ({
30
+ export const useSTTStore = create<STTStoreState>((set) => ({
31
31
  isRecording: false,
32
32
  metering: 0,
33
33
  setRecording: (isRecording) => set({ isRecording }),
@@ -119,6 +119,8 @@ export function useSpeechToText(options: UseSTTOptions = {}) {
119
119
  // Read audio file as base64
120
120
  const response = await fetch(uri);
121
121
  const blob = await response.blob();
122
+ // Detect actual MIME type (web records webm, native records m4a)
123
+ const detectedFormat = blob.type?.split(';')[0] || 'audio/m4a';
122
124
  const base64 = await new Promise<string>((resolve, reject) => {
123
125
  const reader = new FileReader();
124
126
  reader.onload = () => {
@@ -139,7 +141,7 @@ export function useSpeechToText(options: UseSTTOptions = {}) {
139
141
  },
140
142
  body: JSON.stringify({
141
143
  audio: base64,
142
- format: 'audio/m4a',
144
+ format: detectedFormat,
143
145
  }),
144
146
  });
145
147
 
package/src/index.ts CHANGED
@@ -2,9 +2,11 @@
2
2
  // @alia.onl/sdk — Alia AI Chat SDK
3
3
  // Reusable chat UI, voice, and streaming components for Oxy apps
4
4
 
5
- // ── Main component ──
5
+ // ── Main components ──
6
6
  export { AliaChatSheet } from './components/AliaChatSheet';
7
7
  export type { AliaChatSheetProps, AliaChatSheetRef } from './components/AliaChatSheet';
8
+ export { AliaChatScreen } from './components/AliaChatScreen';
9
+ export type { AliaChatScreenProps } from './components/AliaChatScreen';
8
10
 
9
11
  // ── Chat UI components ──
10
12
  export { AliaFace } from './components/AliaFace';
@@ -28,7 +30,7 @@ export { useAudioLevelMonitor } from './hooks/useAudioLevelMonitor';
28
30
  export { useAudioLevels } from './hooks/useAudioLevels';
29
31
  export { useTTS } from './hooks/useTTS';
30
32
  export type { UseTTSOptions } from './hooks/useTTS';
31
- export { useSpeechToText } from './hooks/useSpeechToText';
33
+ export { useSpeechToText, useSTTStore } from './hooks/useSpeechToText';
32
34
  export type { UseSTTOptions } from './hooks/useSpeechToText';
33
35
  export { useSoundEffects, useVoiceSoundEffects } from './hooks/useSoundEffects';
34
36
  export type { SoundName, SoundSources } from './hooks/useSoundEffects';
@@ -46,6 +48,35 @@ export type {
46
48
  VoiceToolInvocation,
47
49
  } from './types';
48
50
 
51
+ // ── UI Primitives (NativeWind) ──
52
+ export { Button, buttonVariants, buttonTextVariants } from './components/ui/button';
53
+ export type { ButtonProps } from './components/ui/button';
54
+ export { Text, TextClassContext } from './components/ui/text';
55
+ export { ChatTextInput } from './components/ui/chat-text-input';
56
+ export { cn, formatFileSize } from './lib/utils';
57
+
58
+ // ── PromptInput ──
59
+ export { PromptInput } from './components/ui/prompt-input/prompt-input';
60
+ export type { PromptInputProps } from './components/ui/prompt-input/prompt-input';
61
+ export { usePromptInput, useIsFullscreen, PromptInputContext } from './components/ui/prompt-input/context';
62
+ export type { Attachment, Completion, PromptInputContextType } from './components/ui/prompt-input/context';
63
+ export { PromptInputTextarea } from './components/ui/prompt-input/textarea';
64
+ export { PromptInputActions } from './components/ui/prompt-input/actions';
65
+ export { PromptInputSubmitButton } from './components/ui/prompt-input/submit-button';
66
+ export { PromptInputMicButton } from './components/ui/prompt-input/mic-button';
67
+ export { PromptInputAddMenu } from './components/ui/prompt-input/add-menu';
68
+ export { PromptInputAttachments } from './components/ui/prompt-input/attachments';
69
+ export { PromptInputAutocomplete } from './components/ui/prompt-input/autocomplete';
70
+
71
+ // ── Picker hooks ──
72
+ export { useImagePicker } from './hooks/useImagePicker';
73
+ export type { ImagePickerAsset } from './hooks/useImagePicker';
74
+ export { useDocumentPicker } from './hooks/useDocumentPicker';
75
+ export type { DocumentPickerResult } from './hooks/useDocumentPicker';
76
+
77
+ // ── Keyboard ──
78
+ export { KeyboardAwareScrollView, KeyboardAvoidingView, KeyboardProvider } from './lib/keyboard';
79
+
49
80
  // ── Theme ──
50
81
  export { useAliaColors } from './theme';
51
82
  export type { AliaColors } from './theme';
@@ -0,0 +1,6 @@
1
+ // Native: re-export react-native-keyboard-controller components
2
+ export {
3
+ KeyboardAwareScrollView,
4
+ KeyboardAvoidingView,
5
+ KeyboardProvider,
6
+ } from 'react-native-keyboard-controller';
@@ -0,0 +1,25 @@
1
+ // Web: re-export React Native built-in components as keyboard-controller substitutes
2
+ import React from 'react';
3
+ import { ScrollView, type ScrollViewProps, KeyboardAvoidingView } from 'react-native';
4
+
5
+ // Accept native-only props so shared components don't cause TS errors
6
+ type KeyboardAwareScrollViewProps = ScrollViewProps & {
7
+ bottomOffset?: number;
8
+ disableScrollOnKeyboardHide?: boolean;
9
+ enabled?: boolean;
10
+ extraKeyboardSpace?: number;
11
+ };
12
+
13
+ const KeyboardAwareScrollView = React.forwardRef<ScrollView, KeyboardAwareScrollViewProps>(
14
+ ({ bottomOffset, disableScrollOnKeyboardHide, enabled, extraKeyboardSpace, ...props }, ref) => (
15
+ <ScrollView ref={ref} {...props} />
16
+ )
17
+ );
18
+ KeyboardAwareScrollView.displayName = 'KeyboardAwareScrollView';
19
+
20
+ export { KeyboardAwareScrollView, KeyboardAvoidingView };
21
+
22
+ // No-op provider on web — keyboard-controller is native-only
23
+ export function KeyboardProvider({ children }: { children: React.ReactNode }) {
24
+ return <>{children}</>;
25
+ }
@@ -0,0 +1,14 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
7
+
8
+ export function formatFileSize(bytes: number): string {
9
+ if (bytes === 0) return '0 B';
10
+ const k = 1024;
11
+ const sizes = ['B', 'KB', 'MB', 'GB'];
12
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
13
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
14
+ }