diff --git a/mobile-app/README.md b/mobile-app/README.md new file mode 100644 index 0000000..a06ce5c --- /dev/null +++ b/mobile-app/README.md @@ -0,0 +1,108 @@ +# Timmy Chat — Mobile App + +A sleek mobile chat interface for Timmy, the sovereign AI agent. Built with **Expo SDK 54**, **React Native**, **TypeScript**, and **NativeWind** (Tailwind CSS). + +## Features + +- **Text Chat** — Send and receive messages with Timmy's full personality +- **Voice Messages** — Record and send voice notes via the mic button; playback with waveform UI +- **Image Sharing** — Take photos or pick from library; full-screen image viewer +- **File Attachments** — Send any document via the system file picker +- **Dark Arcane Theme** — Deep purple/indigo palette matching the Timmy Time dashboard + +## Screenshots + +The app is a single-screen chat interface with: +- Header showing Timmy's status and a clear-chat button +- Message list with distinct user (teal) and Timmy (dark surface) bubbles +- Input bar with attachment (+), text field, and mic/send button +- Empty state with Timmy branding when no messages exist + +## Project Structure + +``` +mobile-app/ +├── app/ # Expo Router screens +│ ├── _layout.tsx # Root layout with providers +│ └── (tabs)/ +│ ├── _layout.tsx # Tab layout (hidden — single screen) +│ └── index.tsx # Main chat screen +├── components/ +│ ├── chat-bubble.tsx # Message bubble (text, image, voice, file) +│ ├── chat-header.tsx # Header with Timmy status +│ ├── chat-input.tsx # Input bar (text, mic, attachments) +│ ├── empty-chat.tsx # Empty state welcome screen +│ ├── image-viewer.tsx # Full-screen image modal +│ └── typing-indicator.tsx # Animated dots while Timmy responds +├── lib/ +│ └── chat-store.tsx # React Context chat state + API calls +├── server/ +│ └── chat.ts # Server-side chat handler with Timmy's prompt +├── shared/ +│ └── types.ts # ChatMessage type definitions +├── assets/images/ # App icons (custom generated) +├── theme.config.js # Color tokens (dark arcane palette) +├── tailwind.config.js # Tailwind/NativeWind configuration +└── tests/ + └── chat.test.ts # Unit tests +``` + +## Setup + +### Prerequisites + +- Node.js 18+ +- pnpm 9+ +- Expo CLI (`npx expo`) +- iOS Simulator or Android Emulator (or physical device with Expo Go) + +### Install Dependencies + +```bash +cd mobile-app +pnpm install +``` + +### Run the App + +```bash +# Start the Expo dev server +npx expo start + +# Or run on specific platform +npx expo start --ios +npx expo start --android +npx expo start --web +``` + +### Backend + +The chat API endpoint (`server/chat.ts`) requires an LLM backend. The `invokeLLM` function should be wired to your preferred provider: + +- **Local Ollama** — Point to `http://localhost:11434` for local inference +- **OpenAI-compatible API** — Any API matching the OpenAI chat completions format + +The system prompt in `server/chat.ts` contains Timmy's full personality, agent roster, and behavioral rules ported from the dashboard's `prompts.py`. + +## Timmy's Personality + +Timmy is a sovereign AI agent — grounded in Christian faith, powered by Bitcoin economics, committed to digital sovereignty. He speaks plainly, acts with intention, and never ends responses with generic chatbot phrases. His agent roster includes Echo, Mace, Forge, Seer, Helm, Quill, Pixel, Lyra, and Reel. + +## Theme + +The app uses a dark arcane color palette: + +| Token | Color | Usage | +|-------|-------|-------| +| `primary` | `#7c3aed` | Accent, user bubbles | +| `background` | `#080412` | Screen background | +| `surface` | `#110a20` | Cards, Timmy bubbles | +| `foreground` | `#e8e0f0` | Primary text | +| `muted` | `#6b5f7d` | Secondary text | +| `border` | `#1e1535` | Dividers | +| `success` | `#22c55e` | Status indicator | +| `error` | `#ff4455` | Recording state | + +## License + +Same as the parent Timmy Time Dashboard project. diff --git a/mobile-app/app.config.ts b/mobile-app/app.config.ts new file mode 100644 index 0000000..f64baf1 --- /dev/null +++ b/mobile-app/app.config.ts @@ -0,0 +1,130 @@ +// Load environment variables with proper priority (system > .env) +import "./scripts/load-env.js"; +import type { ExpoConfig } from "expo/config"; + +// Bundle ID format: space.manus.. +// e.g., "my-app" created at 2024-01-15 10:30:45 -> "space.manus.my.app.t20240115103045" +// Bundle ID can only contain letters, numbers, and dots +// Android requires each dot-separated segment to start with a letter +const rawBundleId = "space.manus.timmy.chat.t20260226211148"; +const bundleId = + rawBundleId + .replace(/[-_]/g, ".") // Replace hyphens/underscores with dots + .replace(/[^a-zA-Z0-9.]/g, "") // Remove invalid chars + .replace(/\.+/g, ".") // Collapse consecutive dots + .replace(/^\.+|\.+$/g, "") // Trim leading/trailing dots + .toLowerCase() + .split(".") + .map((segment) => { + // Android requires each segment to start with a letter + // Prefix with 'x' if segment starts with a digit + return /^[a-zA-Z]/.test(segment) ? segment : "x" + segment; + }) + .join(".") || "space.manus.app"; +// Extract timestamp from bundle ID and prefix with "manus" for deep link scheme +// e.g., "space.manus.my.app.t20240115103045" -> "manus20240115103045" +const timestamp = bundleId.split(".").pop()?.replace(/^t/, "") ?? ""; +const schemeFromBundleId = `manus${timestamp}`; + +const env = { + // App branding - update these values directly (do not use env vars) + appName: "Timmy Chat", + appSlug: "timmy-chat", + // S3 URL of the app logo - set this to the URL returned by generate_image when creating custom logo + // Leave empty to use the default icon from assets/images/icon.png + logoUrl: "https://files.manuscdn.com/user_upload_by_module/session_file/310519663286296482/kuSmtQpNVBtvECMG.png", + scheme: schemeFromBundleId, + iosBundleId: bundleId, + androidPackage: bundleId, +}; + +const config: ExpoConfig = { + name: env.appName, + slug: env.appSlug, + version: "1.0.0", + orientation: "portrait", + icon: "./assets/images/icon.png", + scheme: env.scheme, + userInterfaceStyle: "automatic", + newArchEnabled: true, + ios: { + supportsTablet: true, + bundleIdentifier: env.iosBundleId, + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false + } + }, + android: { + adaptiveIcon: { + backgroundColor: "#080412", + foregroundImage: "./assets/images/android-icon-foreground.png", + backgroundImage: "./assets/images/android-icon-background.png", + monochromeImage: "./assets/images/android-icon-monochrome.png", + }, + edgeToEdgeEnabled: true, + predictiveBackGestureEnabled: false, + package: env.androidPackage, + permissions: ["POST_NOTIFICATIONS"], + intentFilters: [ + { + action: "VIEW", + autoVerify: true, + data: [ + { + scheme: env.scheme, + host: "*", + }, + ], + category: ["BROWSABLE", "DEFAULT"], + }, + ], + }, + web: { + bundler: "metro", + output: "static", + favicon: "./assets/images/favicon.png", + }, + plugins: [ + "expo-router", + [ + "expo-audio", + { + microphonePermission: "Allow $(PRODUCT_NAME) to access your microphone.", + }, + ], + [ + "expo-video", + { + supportsBackgroundPlayback: true, + supportsPictureInPicture: true, + }, + ], + [ + "expo-splash-screen", + { + image: "./assets/images/splash-icon.png", + imageWidth: 200, + resizeMode: "contain", + backgroundColor: "#080412", + dark: { + backgroundColor: "#080412", + }, + }, + ], + [ + "expo-build-properties", + { + android: { + buildArchs: ["armeabi-v7a", "arm64-v8a"], + minSdkVersion: 24, + }, + }, + ], + ], + experiments: { + typedRoutes: true, + reactCompiler: true, + }, +}; + +export default config; diff --git a/mobile-app/app/(tabs)/_layout.tsx b/mobile-app/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..07f5be2 --- /dev/null +++ b/mobile-app/app/(tabs)/_layout.tsx @@ -0,0 +1,17 @@ +import { Tabs } from "expo-router"; +import { useColors } from "@/hooks/use-colors"; + +export default function TabLayout() { + const colors = useColors(); + + return ( + + + + ); +} diff --git a/mobile-app/app/(tabs)/index.tsx b/mobile-app/app/(tabs)/index.tsx new file mode 100644 index 0000000..d747b74 --- /dev/null +++ b/mobile-app/app/(tabs)/index.tsx @@ -0,0 +1,96 @@ +import { useCallback, useRef, useState } from "react"; +import { FlatList, KeyboardAvoidingView, Platform, StyleSheet, View } from "react-native"; +import { ScreenContainer } from "@/components/screen-container"; +import { ChatHeader } from "@/components/chat-header"; +import { ChatBubble } from "@/components/chat-bubble"; +import { ChatInput } from "@/components/chat-input"; +import { TypingIndicator } from "@/components/typing-indicator"; +import { ImageViewer } from "@/components/image-viewer"; +import { EmptyChat } from "@/components/empty-chat"; +import { useChat } from "@/lib/chat-store"; +import { useColors } from "@/hooks/use-colors"; +import { createAudioPlayer, setAudioModeAsync } from "expo-audio"; +import type { ChatMessage } from "@/shared/types"; + +export default function ChatScreen() { + const { messages, isTyping } = useChat(); + const colors = useColors(); + const flatListRef = useRef(null); + const [viewingImage, setViewingImage] = useState(null); + const [playingVoiceId, setPlayingVoiceId] = useState(null); + + const handlePlayVoice = useCallback(async (msg: ChatMessage) => { + if (!msg.uri) return; + try { + if (playingVoiceId === msg.id) { + setPlayingVoiceId(null); + return; + } + await setAudioModeAsync({ playsInSilentMode: true }); + const player = createAudioPlayer({ uri: msg.uri }); + player.play(); + setPlayingVoiceId(msg.id); + // Auto-reset after estimated duration + const dur = (msg.duration ?? 5) * 1000; + setTimeout(() => { + setPlayingVoiceId(null); + player.remove(); + }, dur + 500); + } catch (err) { + console.warn("Voice playback error:", err); + setPlayingVoiceId(null); + } + }, [playingVoiceId]); + + const renderItem = useCallback( + ({ item }: { item: ChatMessage }) => ( + + ), + [playingVoiceId, handlePlayVoice], + ); + + const keyExtractor = useCallback((item: ChatMessage) => item.id, []); + + return ( + + + + + { + flatListRef.current?.scrollToEnd({ animated: true }); + }} + ListFooterComponent={isTyping ? : null} + ListEmptyComponent={!isTyping ? : null} + showsVerticalScrollIndicator={false} + /> + + + + + setViewingImage(null)} /> + + ); +} + +const styles = StyleSheet.create({ + flex: { flex: 1 }, + listContent: { + paddingVertical: 12, + }, +}); diff --git a/mobile-app/app/_layout.tsx b/mobile-app/app/_layout.tsx new file mode 100644 index 0000000..844280f --- /dev/null +++ b/mobile-app/app/_layout.tsx @@ -0,0 +1,45 @@ +import "@/global.css"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Stack } from "expo-router"; +import { StatusBar } from "expo-status-bar"; +import { useState } from "react"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import "react-native-reanimated"; +import { ThemeProvider } from "@/lib/theme-provider"; +import { SafeAreaProvider, initialWindowMetrics } from "react-native-safe-area-context"; +import { ChatProvider } from "@/lib/chat-store"; + +export const unstable_settings = { + anchor: "(tabs)", +}; + +export default function RootLayout() { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + }, + }, + }), + ); + + return ( + + + + + + + + + + + + + + + ); +} diff --git a/mobile-app/assets/images/android-icon-background.png b/mobile-app/assets/images/android-icon-background.png new file mode 100644 index 0000000..5ffefc5 Binary files /dev/null and b/mobile-app/assets/images/android-icon-background.png differ diff --git a/mobile-app/assets/images/android-icon-foreground.png b/mobile-app/assets/images/android-icon-foreground.png new file mode 100644 index 0000000..bf882e0 Binary files /dev/null and b/mobile-app/assets/images/android-icon-foreground.png differ diff --git a/mobile-app/assets/images/android-icon-monochrome.png b/mobile-app/assets/images/android-icon-monochrome.png new file mode 100644 index 0000000..77484eb Binary files /dev/null and b/mobile-app/assets/images/android-icon-monochrome.png differ diff --git a/mobile-app/assets/images/favicon.png b/mobile-app/assets/images/favicon.png new file mode 100644 index 0000000..bf891e5 Binary files /dev/null and b/mobile-app/assets/images/favicon.png differ diff --git a/mobile-app/assets/images/icon.png b/mobile-app/assets/images/icon.png new file mode 100644 index 0000000..bf882e0 Binary files /dev/null and b/mobile-app/assets/images/icon.png differ diff --git a/mobile-app/assets/images/splash-icon.png b/mobile-app/assets/images/splash-icon.png new file mode 100644 index 0000000..d986b19 Binary files /dev/null and b/mobile-app/assets/images/splash-icon.png differ diff --git a/mobile-app/components/chat-bubble.tsx b/mobile-app/components/chat-bubble.tsx new file mode 100644 index 0000000..f4e64f9 --- /dev/null +++ b/mobile-app/components/chat-bubble.tsx @@ -0,0 +1,214 @@ +import { useMemo } from "react"; +import { Text, View, StyleSheet, Image, Platform } from "react-native"; +import Pressable from "@/components/ui/pressable-fix"; +import { useColors } from "@/hooks/use-colors"; +import type { ChatMessage } from "@/shared/types"; +import { formatBytes, formatDuration } from "@/lib/chat-store"; +import MaterialIcons from "@expo/vector-icons/MaterialIcons"; + +interface ChatBubbleProps { + message: ChatMessage; + onImagePress?: (uri: string) => void; + onPlayVoice?: (message: ChatMessage) => void; + isPlayingVoice?: boolean; +} + +export function ChatBubble({ message, onImagePress, onPlayVoice, isPlayingVoice }: ChatBubbleProps) { + const colors = useColors(); + const isUser = message.role === "user"; + + // Stable waveform bar heights based on message id + const waveHeights = useMemo(() => { + let seed = 0; + for (let i = 0; i < message.id.length; i++) seed = (seed * 31 + message.id.charCodeAt(i)) | 0; + return Array.from({ length: 12 }, (_, i) => { + seed = (seed * 16807 + i * 1013) % 2147483647; + return 4 + (seed % 15); + }); + }, [message.id]); + + const bubbleStyle = [ + styles.bubble, + { + backgroundColor: isUser ? colors.primary : colors.surface, + borderColor: isUser ? colors.primary : colors.border, + alignSelf: isUser ? "flex-end" as const : "flex-start" as const, + }, + ]; + + const textColor = isUser ? "#fff" : colors.foreground; + const mutedColor = isUser ? "rgba(255,255,255,0.6)" : colors.muted; + + const timeStr = new Date(message.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + + return ( + + {!isUser && ( + + T + + )} + + {message.contentType === "text" && ( + {message.text} + )} + + {message.contentType === "image" && ( + message.uri && onImagePress?.(message.uri)} + style={({ pressed }) => [pressed && { opacity: 0.8 }]} + > + + {message.text ? ( + + {message.text} + + ) : null} + + )} + + {message.contentType === "voice" && ( + onPlayVoice?.(message)} + style={({ pressed }) => [styles.voiceRow, pressed && { opacity: 0.7 }]} + > + + + {waveHeights.map((h, i) => ( + + ))} + + + {formatDuration(message.duration ?? 0)} + + + )} + + {message.contentType === "file" && ( + + + + + {message.fileName ?? "File"} + + + {formatBytes(message.fileSize ?? 0)} + + + + )} + + {timeStr} + + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: "row", + marginBottom: 8, + paddingHorizontal: 12, + alignItems: "flex-end", + }, + rowUser: { + justifyContent: "flex-end", + }, + rowAssistant: { + justifyContent: "flex-start", + }, + avatar: { + width: 30, + height: 30, + borderRadius: 15, + alignItems: "center", + justifyContent: "center", + marginRight: 8, + }, + avatarText: { + color: "#fff", + fontWeight: "700", + fontSize: 14, + }, + bubble: { + maxWidth: "78%", + borderRadius: 16, + borderWidth: 1, + paddingHorizontal: 14, + paddingVertical: 10, + }, + text: { + fontSize: 15, + lineHeight: 21, + }, + time: { + fontSize: 10, + marginTop: 4, + textAlign: "right", + }, + image: { + width: 220, + height: 180, + borderRadius: 10, + }, + voiceRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + minWidth: 160, + }, + waveform: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: 2, + height: 24, + borderRadius: 4, + paddingHorizontal: 4, + }, + waveBar: { + width: 3, + borderRadius: 1.5, + }, + duration: { + fontSize: 12, + minWidth: 32, + }, + fileRow: { + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + fileInfo: { + flex: 1, + }, + fileName: { + fontSize: 14, + fontWeight: "600", + }, + fileSize: { + fontSize: 11, + marginTop: 2, + }, +}); diff --git a/mobile-app/components/chat-header.tsx b/mobile-app/components/chat-header.tsx new file mode 100644 index 0000000..4fcdc17 --- /dev/null +++ b/mobile-app/components/chat-header.tsx @@ -0,0 +1,69 @@ +import { View, Text, StyleSheet } from "react-native"; +import Pressable from "@/components/ui/pressable-fix"; +import MaterialIcons from "@expo/vector-icons/MaterialIcons"; +import { useColors } from "@/hooks/use-colors"; +import { useChat } from "@/lib/chat-store"; + +export function ChatHeader() { + const colors = useColors(); + const { clearChat } = useChat(); + + return ( + + + + TIMMY + SOVEREIGN AI + + [ + styles.clearBtn, + { borderColor: colors.border }, + pressed && { opacity: 0.6 }, + ]} + > + + + + ); +} + +const styles = StyleSheet.create({ + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 10, + borderBottomWidth: 1, + }, + left: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + title: { + fontSize: 16, + fontWeight: "700", + letterSpacing: 2, + }, + subtitle: { + fontSize: 9, + letterSpacing: 1.5, + fontWeight: "600", + }, + clearBtn: { + width: 32, + height: 32, + borderRadius: 16, + borderWidth: 1, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/mobile-app/components/chat-input.tsx b/mobile-app/components/chat-input.tsx new file mode 100644 index 0000000..5654555 --- /dev/null +++ b/mobile-app/components/chat-input.tsx @@ -0,0 +1,301 @@ +import { useCallback, useRef, useState } from "react"; +import { + View, + TextInput, + StyleSheet, + Platform, + ActionSheetIOS, + Alert, + Keyboard, +} from "react-native"; +import Pressable from "@/components/ui/pressable-fix"; +import MaterialIcons from "@expo/vector-icons/MaterialIcons"; +import { useColors } from "@/hooks/use-colors"; +import { useChat } from "@/lib/chat-store"; +import * as ImagePicker from "expo-image-picker"; +import * as DocumentPicker from "expo-document-picker"; +import { + useAudioRecorder, + useAudioRecorderState, + RecordingPresets, + requestRecordingPermissionsAsync, + setAudioModeAsync, +} from "expo-audio"; +import * as Haptics from "expo-haptics"; + +export function ChatInput() { + const colors = useColors(); + const { sendTextMessage, sendAttachment, isTyping } = useChat(); + const [text, setText] = useState(""); + const [isRecording, setIsRecording] = useState(false); + const inputRef = useRef(null); + + const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); + const recorderState = useAudioRecorderState(audioRecorder); + + const handleSend = useCallback(() => { + const trimmed = text.trim(); + if (!trimmed) return; + setText(""); + Keyboard.dismiss(); + if (Platform.OS !== "web") { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + sendTextMessage(trimmed); + }, [text, sendTextMessage]); + + // ── Attachment sheet ──────────────────────────────────────────────────── + + const handleAttachment = useCallback(() => { + if (Platform.OS !== "web") { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + + const options = ["Take Photo", "Choose from Library", "Choose File", "Cancel"]; + const cancelIndex = 3; + + if (Platform.OS === "ios") { + ActionSheetIOS.showActionSheetWithOptions( + { options, cancelButtonIndex: cancelIndex }, + (idx) => { + if (idx === 0) takePhoto(); + else if (idx === 1) pickImage(); + else if (idx === 2) pickFile(); + }, + ); + } else { + // Android / Web fallback + Alert.alert("Attach", "Choose an option", [ + { text: "Take Photo", onPress: takePhoto }, + { text: "Choose from Library", onPress: pickImage }, + { text: "Choose File", onPress: pickFile }, + { text: "Cancel", style: "cancel" }, + ]); + } + }, []); + + const takePhoto = async () => { + const { status } = await ImagePicker.requestCameraPermissionsAsync(); + if (status !== "granted") { + Alert.alert("Permission needed", "Camera access is required to take photos."); + return; + } + const result = await ImagePicker.launchCameraAsync({ + quality: 0.8, + allowsEditing: false, + }); + if (!result.canceled && result.assets[0]) { + const asset = result.assets[0]; + sendAttachment({ + contentType: "image", + uri: asset.uri, + fileName: asset.fileName ?? "photo.jpg", + fileSize: asset.fileSize, + mimeType: asset.mimeType ?? "image/jpeg", + }); + } + }; + + const pickImage = async () => { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + quality: 0.8, + allowsEditing: false, + }); + if (!result.canceled && result.assets[0]) { + const asset = result.assets[0]; + sendAttachment({ + contentType: "image", + uri: asset.uri, + fileName: asset.fileName ?? "image.jpg", + fileSize: asset.fileSize, + mimeType: asset.mimeType ?? "image/jpeg", + }); + } + }; + + const pickFile = async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ + type: "*/*", + copyToCacheDirectory: true, + }); + if (!result.canceled && result.assets[0]) { + const asset = result.assets[0]; + sendAttachment({ + contentType: "file", + uri: asset.uri, + fileName: asset.name, + fileSize: asset.size, + mimeType: asset.mimeType ?? "application/octet-stream", + }); + } + } catch (err) { + console.warn("Document picker error:", err); + } + }; + + // ── Voice recording ─────────────────────────────────────────────────── + + const startRecording = async () => { + try { + const { granted } = await requestRecordingPermissionsAsync(); + if (!granted) { + Alert.alert("Permission needed", "Microphone access is required for voice messages."); + return; + } + await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true }); + await audioRecorder.prepareToRecordAsync(); + audioRecorder.record(); + setIsRecording(true); + if (Platform.OS !== "web") { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } + } catch (err) { + console.warn("Recording start error:", err); + } + }; + + const stopRecording = async () => { + try { + await audioRecorder.stop(); + setIsRecording(false); + if (Platform.OS !== "web") { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + const uri = audioRecorder.uri; + if (uri) { + const duration = recorderState.durationMillis ? recorderState.durationMillis / 1000 : 0; + sendAttachment({ + contentType: "voice", + uri, + fileName: "voice_message.m4a", + mimeType: "audio/m4a", + duration, + }); + } + } catch (err) { + console.warn("Recording stop error:", err); + setIsRecording(false); + } + }; + + const handleMicPress = useCallback(() => { + if (isRecording) { + stopRecording(); + } else { + startRecording(); + } + }, [isRecording]); + + const hasText = text.trim().length > 0; + + return ( + + {/* Attachment button */} + [ + styles.iconBtn, + { backgroundColor: colors.surface }, + pressed && { opacity: 0.6 }, + ]} + disabled={isTyping} + > + + + + {/* Text input */} + + + {/* Send or Mic button */} + {hasText ? ( + [ + styles.sendBtn, + { backgroundColor: colors.primary }, + pressed && { transform: [{ scale: 0.95 }], opacity: 0.9 }, + ]} + disabled={isTyping} + > + + + ) : ( + [ + styles.sendBtn, + { + backgroundColor: isRecording ? colors.error : colors.surface, + }, + pressed && { transform: [{ scale: 0.95 }], opacity: 0.9 }, + ]} + disabled={isTyping} + > + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "flex-end", + paddingHorizontal: 10, + paddingVertical: 8, + gap: 8, + borderTopWidth: 1, + }, + iconBtn: { + width: 38, + height: 38, + borderRadius: 19, + alignItems: "center", + justifyContent: "center", + }, + input: { + flex: 1, + minHeight: 38, + maxHeight: 120, + borderRadius: 19, + borderWidth: 1, + paddingHorizontal: 14, + paddingVertical: 8, + fontSize: 15, + lineHeight: 20, + }, + sendBtn: { + width: 38, + height: 38, + borderRadius: 19, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/mobile-app/components/empty-chat.tsx b/mobile-app/components/empty-chat.tsx new file mode 100644 index 0000000..f2a488c --- /dev/null +++ b/mobile-app/components/empty-chat.tsx @@ -0,0 +1,55 @@ +import { View, Text, StyleSheet } from "react-native"; +import { useColors } from "@/hooks/use-colors"; +import MaterialIcons from "@expo/vector-icons/MaterialIcons"; + +export function EmptyChat() { + const colors = useColors(); + + return ( + + + + + TIMMY + SOVEREIGN AI AGENT + + Send a message, voice note, image, or file to get started. + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 40, + gap: 8, + }, + iconCircle: { + width: 80, + height: 80, + borderRadius: 40, + borderWidth: 1, + alignItems: "center", + justifyContent: "center", + marginBottom: 12, + }, + title: { + fontSize: 24, + fontWeight: "700", + letterSpacing: 4, + }, + subtitle: { + fontSize: 11, + letterSpacing: 2, + fontWeight: "600", + }, + hint: { + fontSize: 13, + textAlign: "center", + marginTop: 12, + lineHeight: 19, + }, +}); diff --git a/mobile-app/components/haptic-tab.tsx b/mobile-app/components/haptic-tab.tsx new file mode 100644 index 0000000..a567476 --- /dev/null +++ b/mobile-app/components/haptic-tab.tsx @@ -0,0 +1,18 @@ +import { BottomTabBarButtonProps } from "@react-navigation/bottom-tabs"; +import { PlatformPressable } from "@react-navigation/elements"; +import * as Haptics from "expo-haptics"; + +export function HapticTab(props: BottomTabBarButtonProps) { + return ( + { + if (process.env.EXPO_OS === "ios") { + // Add a soft haptic feedback when pressing down on the tabs. + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + props.onPressIn?.(ev); + }} + /> + ); +} diff --git a/mobile-app/components/image-viewer.tsx b/mobile-app/components/image-viewer.tsx new file mode 100644 index 0000000..e37dfe0 --- /dev/null +++ b/mobile-app/components/image-viewer.tsx @@ -0,0 +1,54 @@ +import { Modal, View, Image, StyleSheet, StatusBar } from "react-native"; +import Pressable from "@/components/ui/pressable-fix"; +import MaterialIcons from "@expo/vector-icons/MaterialIcons"; + +interface ImageViewerProps { + uri: string | null; + onClose: () => void; +} + +export function ImageViewer({ uri, onClose }: ImageViewerProps) { + if (!uri) return null; + + return ( + + + + + [ + styles.closeBtn, + pressed && { opacity: 0.6 }, + ]} + > + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.95)", + justifyContent: "center", + alignItems: "center", + }, + image: { + width: "100%", + height: "80%", + }, + closeBtn: { + position: "absolute", + top: 50, + right: 20, + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: "rgba(255,255,255,0.15)", + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/mobile-app/components/screen-container.tsx b/mobile-app/components/screen-container.tsx new file mode 100644 index 0000000..20b0b99 --- /dev/null +++ b/mobile-app/components/screen-container.tsx @@ -0,0 +1,68 @@ +import { View, type ViewProps } from "react-native"; +import { SafeAreaView, type Edge } from "react-native-safe-area-context"; + +import { cn } from "@/lib/utils"; + +export interface ScreenContainerProps extends ViewProps { + /** + * SafeArea edges to apply. Defaults to ["top", "left", "right"]. + * Bottom is typically handled by Tab Bar. + */ + edges?: Edge[]; + /** + * Tailwind className for the content area. + */ + className?: string; + /** + * Additional className for the outer container (background layer). + */ + containerClassName?: string; + /** + * Additional className for the SafeAreaView (content layer). + */ + safeAreaClassName?: string; +} + +/** + * A container component that properly handles SafeArea and background colors. + * + * The outer View extends to full screen (including status bar area) with the background color, + * while the inner SafeAreaView ensures content is within safe bounds. + * + * Usage: + * ```tsx + * + * + * Welcome + * + * + * ``` + */ +export function ScreenContainer({ + children, + edges = ["top", "left", "right"], + className, + containerClassName, + safeAreaClassName, + style, + ...props +}: ScreenContainerProps) { + return ( + + + {children} + + + ); +} diff --git a/mobile-app/components/themed-view.tsx b/mobile-app/components/themed-view.tsx new file mode 100644 index 0000000..2959350 --- /dev/null +++ b/mobile-app/components/themed-view.tsx @@ -0,0 +1,15 @@ +import { View, type ViewProps } from "react-native"; + +import { cn } from "@/lib/utils"; + +export interface ThemedViewProps extends ViewProps { + className?: string; +} + +/** + * A View component with automatic theme-aware background. + * Uses NativeWind for styling - pass className for additional styles. + */ +export function ThemedView({ className, ...otherProps }: ThemedViewProps) { + return ; +} diff --git a/mobile-app/components/typing-indicator.tsx b/mobile-app/components/typing-indicator.tsx new file mode 100644 index 0000000..6dcee97 --- /dev/null +++ b/mobile-app/components/typing-indicator.tsx @@ -0,0 +1,89 @@ +import { useEffect } from "react"; +import { View, StyleSheet } from "react-native"; +import Animated, { + useSharedValue, + useAnimatedStyle, + withRepeat, + withTiming, + withDelay, + withSequence, +} from "react-native-reanimated"; +import { useColors } from "@/hooks/use-colors"; + +export function TypingIndicator() { + const colors = useColors(); + const dot1 = useSharedValue(0.3); + const dot2 = useSharedValue(0.3); + const dot3 = useSharedValue(0.3); + + useEffect(() => { + const anim = (sv: { value: number }, delay: number) => { + sv.value = withDelay( + delay, + withRepeat( + withSequence( + withTiming(1, { duration: 400 }), + withTiming(0.3, { duration: 400 }), + ), + -1, + ), + ); + }; + anim(dot1, 0); + anim(dot2, 200); + anim(dot3, 400); + }, []); + + const style1 = useAnimatedStyle(() => ({ opacity: dot1.value })); + const style2 = useAnimatedStyle(() => ({ opacity: dot2.value })); + const style3 = useAnimatedStyle(() => ({ opacity: dot3.value })); + + const dotBase = [styles.dot, { backgroundColor: colors.primary }]; + + return ( + + + T + + + + + + + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: "row", + paddingHorizontal: 12, + marginBottom: 8, + }, + avatar: { + width: 30, + height: 30, + borderRadius: 15, + alignItems: "center", + justifyContent: "center", + marginRight: 8, + }, + avatarText: { + color: "#fff", + fontWeight: "700", + fontSize: 14, + }, + bubble: { + flexDirection: "row", + gap: 5, + paddingHorizontal: 16, + paddingVertical: 14, + borderRadius: 16, + borderWidth: 1, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + }, +}); diff --git a/mobile-app/components/ui/icon-symbol.tsx b/mobile-app/components/ui/icon-symbol.tsx new file mode 100644 index 0000000..12c226c --- /dev/null +++ b/mobile-app/components/ui/icon-symbol.tsx @@ -0,0 +1,41 @@ +// Fallback for using MaterialIcons on Android and web. + +import MaterialIcons from "@expo/vector-icons/MaterialIcons"; +import { SymbolWeight, SymbolViewProps } from "expo-symbols"; +import { ComponentProps } from "react"; +import { OpaqueColorValue, type StyleProp, type TextStyle } from "react-native"; + +type IconMapping = Record["name"]>; +type IconSymbolName = keyof typeof MAPPING; + +/** + * Add your SF Symbols to Material Icons mappings here. + * - see Material Icons in the [Icons Directory](https://icons.expo.fyi). + * - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app. + */ +const MAPPING = { + "house.fill": "home", + "paperplane.fill": "send", + "chevron.left.forwardslash.chevron.right": "code", + "chevron.right": "chevron-right", +} as IconMapping; + +/** + * An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web. + * This ensures a consistent look across platforms, and optimal resource usage. + * Icon `name`s are based on SF Symbols and require manual mapping to Material Icons. + */ +export function IconSymbol({ + name, + size = 24, + color, + style, +}: { + name: IconSymbolName; + size?: number; + color: string | OpaqueColorValue; + style?: StyleProp; + weight?: SymbolWeight; +}) { + return ; +} diff --git a/mobile-app/components/ui/pressable-fix.tsx b/mobile-app/components/ui/pressable-fix.tsx new file mode 100644 index 0000000..d44c0d5 --- /dev/null +++ b/mobile-app/components/ui/pressable-fix.tsx @@ -0,0 +1,6 @@ +/** + * Re-export Pressable with proper typing for style callbacks. + * NativeWind disables className on Pressable, so we always use the style prop. + */ +import { Pressable } from "react-native"; +export default Pressable; diff --git a/mobile-app/constants/theme.ts b/mobile-app/constants/theme.ts new file mode 100644 index 0000000..7dc18b1 --- /dev/null +++ b/mobile-app/constants/theme.ts @@ -0,0 +1,12 @@ +/** + * Thin re-exports so consumers don't need to know about internal theme plumbing. + * Full implementation lives in lib/_core/theme.ts. + */ +export { + Colors, + Fonts, + SchemeColors, + ThemeColors, + type ColorScheme, + type ThemeColorPalette, +} from "@/lib/_core/theme"; diff --git a/mobile-app/design.md b/mobile-app/design.md new file mode 100644 index 0000000..275a985 --- /dev/null +++ b/mobile-app/design.md @@ -0,0 +1,80 @@ +# Timmy Chat — Mobile App Design + +## Overview +A sleek, single-screen chat app for talking to Timmy — the sovereign AI agent from the Timmy Time dashboard. Supports text, voice, image, and file messaging. Dark arcane theme matching Mission Control. + +## Screen List + +### 1. Chat Screen (Home / Only Screen) +The entire app is a single full-screen chat interface. No tabs, no settings, no extra screens. Just you and Timmy. + +### 2. No Other Screens +No settings, no profile, no onboarding. The app opens straight to chat. + +## Primary Content and Functionality + +### Chat Screen +- **Header**: "TIMMY" title with status indicator (online/offline dot), minimal and clean +- **Message List**: Full-screen scrollable message list (FlatList, inverted) + - User messages: right-aligned, purple/violet accent bubble + - Timmy messages: left-aligned, dark surface bubble with avatar initial "T" + - Image messages: thumbnail preview in bubble, tappable for full-screen + - File messages: file icon + filename + size in bubble + - Voice messages: waveform-style playback bar with play/pause + duration + - Timestamps shown subtly below message groups +- **Input Bar** (bottom, always visible): + - Text input field (expandable, multi-line) + - Attachment button (left of input) — opens action sheet: Camera, Photo Library, File + - Voice record button (right of input, replaces send when input is empty) + - Send button (right of input, appears when text is entered) + - Hold-to-record voice: press and hold mic icon, release to send + +## Key User Flows + +### Text Chat +1. User types message → taps Send +2. Message appears in chat as "sending" +3. Server responds → Timmy's reply appears below + +### Voice Message +1. User presses and holds mic button +2. Recording indicator appears (duration + pulsing dot) +3. User releases → voice message sent +4. Timmy responds with text (server processes audio) + +### Image Sharing +1. User taps attachment (+) button +2. Action sheet: "Take Photo" / "Choose from Library" +3. Image appears as thumbnail in chat +4. Timmy acknowledges receipt + +### File Sharing +1. User taps attachment (+) button → "Choose File" +2. Document picker opens +3. File appears in chat with name + size +4. Timmy acknowledges receipt + +## Color Choices (Arcane Dark Theme) + +Matching the Timmy Time Mission Control dashboard: + +| Token | Dark Value | Purpose | +|-------------|-------------|--------------------------------| +| background | #080412 | Deep dark purple-black | +| surface | #110820 | Card/bubble background | +| foreground | #ede0ff | Primary text (bright lavender) | +| muted | #6b4a8a | Secondary/timestamp text | +| primary | #a855f7 | Accent purple (user bubbles) | +| border | #3b1a5c | Subtle borders | +| success | #00e87a | Online status, success | +| warning | #ffb800 | Amber warnings | +| error | #ff4455 | Error states | + +## Layout Specifics (Portrait 9:16, One-Handed) + +- Input bar pinned to bottom with safe area padding +- Send/mic button on right (thumb-reachable) +- Attachment button on left of input +- Messages fill remaining space above input +- No tab bar — single screen app +- Header is compact (44pt) with just title + status dot diff --git a/mobile-app/global.css b/mobile-app/global.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/mobile-app/global.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/mobile-app/hooks/use-color-scheme.ts b/mobile-app/hooks/use-color-scheme.ts new file mode 100644 index 0000000..670e0f0 --- /dev/null +++ b/mobile-app/hooks/use-color-scheme.ts @@ -0,0 +1,5 @@ +import { useThemeContext } from "@/lib/theme-provider"; + +export function useColorScheme() { + return useThemeContext().colorScheme; +} diff --git a/mobile-app/hooks/use-color-scheme.web.ts b/mobile-app/hooks/use-color-scheme.web.ts new file mode 100644 index 0000000..66cccac --- /dev/null +++ b/mobile-app/hooks/use-color-scheme.web.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; +import { useColorScheme as useRNColorScheme } from "react-native"; + +/** + * To support static rendering, this value needs to be re-calculated on the client side for web + */ +export function useColorScheme() { + const [hasHydrated, setHasHydrated] = useState(false); + + useEffect(() => { + setHasHydrated(true); + }, []); + + const colorScheme = useRNColorScheme(); + + if (hasHydrated) { + return colorScheme; + } + + return "light"; +} diff --git a/mobile-app/hooks/use-colors.ts b/mobile-app/hooks/use-colors.ts new file mode 100644 index 0000000..f891d27 --- /dev/null +++ b/mobile-app/hooks/use-colors.ts @@ -0,0 +1,12 @@ +import { Colors, type ColorScheme, type ThemeColorPalette } from "@/constants/theme"; +import { useColorScheme } from "./use-color-scheme"; + +/** + * Returns the current theme's color palette. + * Usage: const colors = useColors(); then colors.text, colors.background, etc. + */ +export function useColors(colorSchemeOverride?: ColorScheme): ThemeColorPalette { + const colorSchema = useColorScheme(); + const scheme = (colorSchemeOverride ?? colorSchema ?? "light") as ColorScheme; + return Colors[scheme]; +} diff --git a/mobile-app/lib/chat-store.tsx b/mobile-app/lib/chat-store.tsx new file mode 100644 index 0000000..f56b51c --- /dev/null +++ b/mobile-app/lib/chat-store.tsx @@ -0,0 +1,298 @@ +import React, { createContext, useCallback, useContext, useReducer, type ReactNode } from "react"; +import type { ChatMessage, MessageContentType } from "@/shared/types"; + +// ── State ─────────────────────────────────────────────────────────────────── + +interface ChatState { + messages: ChatMessage[]; + isTyping: boolean; +} + +const initialState: ChatState = { + messages: [], + isTyping: false, +}; + +// ── Actions ───────────────────────────────────────────────────────────────── + +type ChatAction = + | { type: "ADD_MESSAGE"; message: ChatMessage } + | { type: "UPDATE_MESSAGE"; id: string; updates: Partial } + | { type: "SET_TYPING"; isTyping: boolean } + | { type: "CLEAR" }; + +function chatReducer(state: ChatState, action: ChatAction): ChatState { + switch (action.type) { + case "ADD_MESSAGE": + return { ...state, messages: [...state.messages, action.message] }; + case "UPDATE_MESSAGE": + return { + ...state, + messages: state.messages.map((m) => + m.id === action.id ? { ...m, ...action.updates } : m, + ), + }; + case "SET_TYPING": + return { ...state, isTyping: action.isTyping }; + case "CLEAR": + return initialState; + default: + return state; + } +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +let _counter = 0; +function makeId(): string { + return `msg_${Date.now()}_${++_counter}`; +} + +// ── Context ───────────────────────────────────────────────────────────────── + +interface ChatContextValue { + messages: ChatMessage[]; + isTyping: boolean; + sendTextMessage: (text: string) => Promise; + sendAttachment: (opts: { + contentType: MessageContentType; + uri: string; + fileName?: string; + fileSize?: number; + mimeType?: string; + duration?: number; + text?: string; + }) => Promise; + clearChat: () => void; +} + +const ChatContext = createContext(null); + +// ── API call ──────────────────────────────────────────────────────────────── + +function getApiBase(): string { + // Set EXPO_PUBLIC_API_BASE_URL in your .env to point to your Timmy backend + // e.g. EXPO_PUBLIC_API_BASE_URL=http://192.168.1.100:3000 + const envBase = process.env.EXPO_PUBLIC_API_BASE_URL; + if (envBase) return envBase.replace(/\/+$/, ""); + // Fallback for web: derive from window location + if (typeof window !== "undefined" && window.location) { + return `${window.location.protocol}//${window.location.hostname}:3000`; + } + // Default: local machine + return "http://127.0.0.1:3000"; +} + +const API_BASE = getApiBase(); + +async function callChatAPI( + messages: Array<{ role: string; content: string | Array> }>, +): Promise { + const res = await fetch(`${API_BASE}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages }), + }); + if (!res.ok) { + const errText = await res.text().catch(() => res.statusText); + throw new Error(`Chat API error: ${errText}`); + } + const data = await res.json(); + return data.reply ?? data.text ?? "..."; +} + +async function uploadFile( + uri: string, + fileName: string, + mimeType: string, +): Promise { + const formData = new FormData(); + formData.append("file", { + uri, + name: fileName, + type: mimeType, + } as unknown as Blob); + + const res = await fetch(`${API_BASE}/api/upload`, { + method: "POST", + body: formData, + }); + if (!res.ok) throw new Error("Upload failed"); + const data = await res.json(); + return data.url; +} + +// ── Provider ──────────────────────────────────────────────────────────────── + +export function ChatProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(chatReducer, initialState); + + const sendTextMessage = useCallback( + async (text: string) => { + const userMsg: ChatMessage = { + id: makeId(), + role: "user", + contentType: "text", + text, + timestamp: Date.now(), + }; + dispatch({ type: "ADD_MESSAGE", message: userMsg }); + dispatch({ type: "SET_TYPING", isTyping: true }); + + try { + // Build conversation context (last 20 messages) + const recent = [...state.messages, userMsg].slice(-20); + const apiMessages = recent + .filter((m) => m.contentType === "text" && m.text) + .map((m) => ({ role: m.role, content: m.text! })); + + const reply = await callChatAPI(apiMessages); + const assistantMsg: ChatMessage = { + id: makeId(), + role: "assistant", + contentType: "text", + text: reply, + timestamp: Date.now(), + }; + dispatch({ type: "ADD_MESSAGE", message: assistantMsg }); + } catch (err: unknown) { + const errorText = err instanceof Error ? err.message : "Something went wrong"; + dispatch({ + type: "ADD_MESSAGE", + message: { + id: makeId(), + role: "assistant", + contentType: "text", + text: `Sorry, I couldn't process that: ${errorText}`, + timestamp: Date.now(), + }, + }); + } finally { + dispatch({ type: "SET_TYPING", isTyping: false }); + } + }, + [state.messages], + ); + + const sendAttachment = useCallback( + async (opts: { + contentType: MessageContentType; + uri: string; + fileName?: string; + fileSize?: number; + mimeType?: string; + duration?: number; + text?: string; + }) => { + const userMsg: ChatMessage = { + id: makeId(), + role: "user", + contentType: opts.contentType, + uri: opts.uri, + fileName: opts.fileName, + fileSize: opts.fileSize, + mimeType: opts.mimeType, + duration: opts.duration, + text: opts.text, + timestamp: Date.now(), + }; + dispatch({ type: "ADD_MESSAGE", message: userMsg }); + dispatch({ type: "SET_TYPING", isTyping: true }); + + try { + // Upload file to server + const remoteUrl = await uploadFile( + opts.uri, + opts.fileName ?? "attachment", + opts.mimeType ?? "application/octet-stream", + ); + dispatch({ type: "UPDATE_MESSAGE", id: userMsg.id, updates: { remoteUrl } }); + + // Build message for LLM + let content: string | Array>; + if (opts.contentType === "image") { + content = [ + { type: "text", text: opts.text || "I'm sending you an image." }, + { type: "image_url", image_url: { url: remoteUrl } }, + ]; + } else if (opts.contentType === "voice") { + content = [ + { type: "text", text: "I'm sending you a voice message. Please transcribe and respond." }, + { type: "file_url", file_url: { url: remoteUrl, mime_type: opts.mimeType ?? "audio/m4a" } }, + ]; + } else { + content = `I'm sharing a file: ${opts.fileName ?? "file"} (${formatBytes(opts.fileSize ?? 0)})`; + } + + const apiMessages = [{ role: "user", content }]; + const reply = await callChatAPI(apiMessages); + + dispatch({ + type: "ADD_MESSAGE", + message: { + id: makeId(), + role: "assistant", + contentType: "text", + text: reply, + timestamp: Date.now(), + }, + }); + } catch (err: unknown) { + const errorText = err instanceof Error ? err.message : "Upload failed"; + dispatch({ + type: "ADD_MESSAGE", + message: { + id: makeId(), + role: "assistant", + contentType: "text", + text: `I had trouble processing that attachment: ${errorText}`, + timestamp: Date.now(), + }, + }); + } finally { + dispatch({ type: "SET_TYPING", isTyping: false }); + } + }, + [], + ); + + const clearChat = useCallback(() => { + dispatch({ type: "CLEAR" }); + }, []); + + return ( + + {children} + + ); +} + +export function useChat(): ChatContextValue { + const ctx = useContext(ChatContext); + if (!ctx) throw new Error("useChat must be used within ChatProvider"); + return ctx; +} + +// ── Utils ─────────────────────────────────────────────────────────────────── + +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +export function formatDuration(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} diff --git a/mobile-app/lib/theme-provider.tsx b/mobile-app/lib/theme-provider.tsx new file mode 100644 index 0000000..5439acc --- /dev/null +++ b/mobile-app/lib/theme-provider.tsx @@ -0,0 +1,79 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { Appearance, View, useColorScheme as useSystemColorScheme } from "react-native"; +import { colorScheme as nativewindColorScheme, vars } from "nativewind"; + +import { SchemeColors, type ColorScheme } from "@/constants/theme"; + +type ThemeContextValue = { + colorScheme: ColorScheme; + setColorScheme: (scheme: ColorScheme) => void; +}; + +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const systemScheme = useSystemColorScheme() ?? "light"; + const [colorScheme, setColorSchemeState] = useState(systemScheme); + + const applyScheme = useCallback((scheme: ColorScheme) => { + nativewindColorScheme.set(scheme); + Appearance.setColorScheme?.(scheme); + if (typeof document !== "undefined") { + const root = document.documentElement; + root.dataset.theme = scheme; + root.classList.toggle("dark", scheme === "dark"); + const palette = SchemeColors[scheme]; + Object.entries(palette).forEach(([token, value]) => { + root.style.setProperty(`--color-${token}`, value); + }); + } + }, []); + + const setColorScheme = useCallback((scheme: ColorScheme) => { + setColorSchemeState(scheme); + applyScheme(scheme); + }, [applyScheme]); + + useEffect(() => { + applyScheme(colorScheme); + }, [applyScheme, colorScheme]); + + const themeVariables = useMemo( + () => + vars({ + "color-primary": SchemeColors[colorScheme].primary, + "color-background": SchemeColors[colorScheme].background, + "color-surface": SchemeColors[colorScheme].surface, + "color-foreground": SchemeColors[colorScheme].foreground, + "color-muted": SchemeColors[colorScheme].muted, + "color-border": SchemeColors[colorScheme].border, + "color-success": SchemeColors[colorScheme].success, + "color-warning": SchemeColors[colorScheme].warning, + "color-error": SchemeColors[colorScheme].error, + }), + [colorScheme], + ); + + const value = useMemo( + () => ({ + colorScheme, + setColorScheme, + }), + [colorScheme, setColorScheme], + ); + console.log(value, themeVariables) + + return ( + + {children} + + ); +} + +export function useThemeContext(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) { + throw new Error("useThemeContext must be used within ThemeProvider"); + } + return ctx; +} diff --git a/mobile-app/lib/utils.ts b/mobile-app/lib/utils.ts new file mode 100644 index 0000000..05eae6b --- /dev/null +++ b/mobile-app/lib/utils.ts @@ -0,0 +1,15 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** + * Combines class names using clsx and tailwind-merge. + * This ensures Tailwind classes are properly merged without conflicts. + * + * Usage: + * ```tsx + * cn("px-4 py-2", isActive && "bg-primary", className) + * ``` + */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/mobile-app/package.json b/mobile-app/package.json new file mode 100644 index 0000000..a12844d --- /dev/null +++ b/mobile-app/package.json @@ -0,0 +1,98 @@ +{ + "name": "app-template", + "version": "1.0.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "dev": "concurrently -k \"pnpm dev:server\" \"pnpm dev:metro\"", + "dev:server": "cross-env NODE_ENV=development tsx watch server/_core/index.ts", + "dev:metro": "cross-env EXPO_USE_METRO_WORKSPACE_ROOT=1 npx expo start --web --port ${EXPO_PORT:-8081}", + "build": "esbuild server/_core/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist", + "start": "NODE_ENV=production node dist/index.js", + "check": "tsc --noEmit", + "lint": "expo lint", + "format": "prettier --write .", + "test": "vitest run", + "db:push": "drizzle-kit generate && drizzle-kit migrate", + "android": "expo start --android", + "ios": "expo start --ios", + "qr": "node scripts/generate_qr.mjs" + }, + "dependencies": { + "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "^2.2.0", + "@react-navigation/bottom-tabs": "^7.8.12", + "@react-navigation/elements": "^2.9.2", + "@react-navigation/native": "^7.1.25", + "@tanstack/react-query": "^5.90.12", + "@trpc/client": "11.7.2", + "@trpc/react-query": "11.7.2", + "@trpc/server": "11.7.2", + "axios": "^1.13.2", + "clsx": "^2.1.1", + "cookie": "^1.1.1", + "dotenv": "^16.6.1", + "drizzle-orm": "^0.44.7", + "expo": "~54.0.29", + "expo-audio": "~1.1.0", + "expo-build-properties": "^1.0.10", + "expo-constants": "~18.0.12", + "expo-document-picker": "~14.0.8", + "expo-file-system": "~19.0.21", + "expo-font": "~14.0.10", + "expo-haptics": "~15.0.8", + "expo-image": "~3.0.11", + "expo-image-picker": "~17.0.10", + "expo-keep-awake": "~15.0.8", + "expo-linking": "~8.0.10", + "expo-notifications": "~0.32.15", + "expo-router": "~6.0.19", + "expo-secure-store": "~15.0.8", + "expo-speech": "~14.0.8", + "expo-splash-screen": "~31.0.12", + "expo-status-bar": "~3.0.9", + "expo-symbols": "~1.0.8", + "expo-system-ui": "~6.0.9", + "expo-video": "~3.0.15", + "expo-web-browser": "~15.0.10", + "express": "^4.22.1", + "jose": "6.1.0", + "mysql2": "^3.16.0", + "nativewind": "^4.2.1", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-native": "0.81.5", + "react-native-gesture-handler": "~2.28.0", + "react-native-reanimated": "~4.1.6", + "react-native-safe-area-context": "~5.6.2", + "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1", + "react-native-web": "~0.21.2", + "react-native-worklets": "0.5.1", + "streamdown": "^2.3.0", + "superjson": "^1.13.3", + "tailwind-merge": "^2.6.0", + "zod": "^4.2.1" + }, + "devDependencies": { + "@expo/ngrok": "^4.1.3", + "@types/cookie": "^0.6.0", + "@types/express": "^4.17.25", + "@types/node": "^22.19.3", + "@types/qrcode": "^1.5.6", + "@types/react": "~19.1.17", + "concurrently": "^9.2.1", + "cross-env": "^7.0.3", + "drizzle-kit": "^0.31.8", + "esbuild": "^0.25.12", + "eslint": "^9.39.2", + "eslint-config-expo": "~10.0.0", + "prettier": "^3.7.4", + "qrcode": "^1.5.4", + "tailwindcss": "^3.4.17", + "tsx": "^4.21.0", + "typescript": "~5.9.3", + "vitest": "^2.1.9" + }, + "packageManager": "pnpm@9.12.0" +} diff --git a/mobile-app/server/README.md b/mobile-app/server/README.md new file mode 100644 index 0000000..246c246 --- /dev/null +++ b/mobile-app/server/README.md @@ -0,0 +1,1235 @@ +# Backend Development Guide + +This guide covers server-side features including authentication, database, tRPC API, and integrations. **Only read this if your app needs these capabilities.** + +--- + +## When Do You Need Backend? + +| Scenario | Backend Needed? | User Auth Required? | Solution | +|----------|-----------------|---------------------|----------| +| Data stays on device only | No | No | Use `AsyncStorage` | +| Data syncs across devices | Yes | Yes | Database + tRPC | +| User accounts / login | Yes | Yes | Manus OAuth | +| AI-powered features | Yes | **Optional** | LLM Integration | +| User uploads files | Yes | **Optional** | S3 Storage | +| Server-side validation | Yes | **Optional** | tRPC procedures | + +> **Note:** Backend ≠ User Auth. You can run a backend with LLM/Storage/ImageGen capabilities without requiring user login — just use `publicProcedure` instead of `protectedProcedure`. User auth is only mandatory when you need to identify users or sync user-specific data. + +--- + +## File Structure + +``` +server/ + db.ts ← Query helpers (add database functions here) + routers.ts ← tRPC procedures (add API routes here) + storage.ts ← S3 storage helpers (can extend) + _core/ ← Framework-level code (don't modify) +drizzle/ + schema.ts ← Database tables & types (add your tables here) + relations.ts ← Table relationships + migrations/ ← Auto-generated migrations +shared/ + types.ts ← Shared TypeScript types + const.ts ← Shared constants + _core/ ← Framework-level code (don't modify) +lib/ + trpc.ts ← tRPC client (can customize headers) + _core/ ← Framework-level code (don't modify) +hooks/ + use-auth.ts ← Auth state hook (don't modify) +tests/ + *.test.ts ← Add your tests here +``` + +Only touch the files with "←" markers. Anything under `_core/` directories is framework-level—avoid editing unless you are extending the infrastructure. + +--- + +## Authentication + +### Overview + +The template uses **Manus OAuth** for user authentication. It works differently on native and web: + +| Platform | Auth Method | Token Storage | +|----------|-------------|---------------| +| iOS/Android | Bearer token | expo-secure-store | +| Web | HTTP-only cookie | Browser cookie | + +### Using the Auth Hook + +```tsx +import { useAuth } from "@/hooks/use-auth"; + +function MyScreen() { + const { user, isAuthenticated, loading, logout } = useAuth(); + + if (loading) return ; + + if (!isAuthenticated) { + return ; + } + + return ( + + Welcome, {user.name} +