feat(mobile): 移动端初始化

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-03 16:39:53 +08:00
parent d6f79d2df6
commit 01c82b296d
38 changed files with 7115 additions and 36 deletions

43
apps/mobile/.gitignore vendored Normal file
View file

@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android

1
apps/mobile/.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
apps/mobile/.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

50
apps/mobile/README.md Normal file
View file

@ -0,0 +1,50 @@
# Welcome to your Expo app 👋
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
## Get started
1. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
## Learn more
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.

48
apps/mobile/app.json Normal file
View file

@ -0,0 +1,48 @@
{
"expo": {
"name": "Multica",
"slug": "multica-mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mobile",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"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
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}

View file

@ -0,0 +1,21 @@
import "../global.css";
import { ThemeProvider } from "@react-navigation/native";
import { PortalHost } from "@rn-primitives/portal";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { useColorScheme } from "nativewind";
import "react-native-reanimated";
import { NAV_THEME } from "@/lib/theme";
export default function RootLayout() {
const { colorScheme } = useColorScheme();
return (
<ThemeProvider value={NAV_THEME[colorScheme ?? "light"]}>
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
<Stack screenOptions={{ headerShown: false }} />
<PortalHost />
</ThemeProvider>
);
}

167
apps/mobile/app/index.tsx Normal file
View file

@ -0,0 +1,167 @@
import { useState, useRef, useCallback } from "react";
import {
View,
ScrollView,
KeyboardAvoidingView,
Platform,
TextInput,
Pressable,
LayoutAnimation,
UIManager,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Text } from "@/components/ui/text";
import { HugeiconsIcon } from "@hugeicons/react-native";
import { ArrowUp01Icon } from "@hugeicons/core-free-icons";
// Enable LayoutAnimation on Android
if (Platform.OS === "android" && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
const INPUT_MAX_HEIGHT = 120;
const SINGLE_LINE_HEIGHT = 20; // approximate single line content height
const MOCK_MESSAGES = [
{ id: "1", role: "user", content: "Hello, can you help me with a task?" },
{
id: "2",
role: "assistant",
content:
"Of course! I'd be happy to help. What would you like me to do?",
},
{ id: "3", role: "user", content: "Explain how WebSockets work." },
{
id: "4",
role: "assistant",
content:
"WebSockets provide full-duplex communication channels over a single TCP connection. Unlike HTTP, which is request-response based, WebSockets allow both the client and server to send messages independently at any time.\n\nThe connection starts as a standard HTTP request, then upgrades to a persistent WebSocket connection via a handshake.",
},
];
export default function Index() {
const [input, setInput] = useState("");
const [messages, setMessages] = useState(MOCK_MESSAGES);
const [contentHeight, setContentHeight] = useState(SINGLE_LINE_HEIGHT);
const scrollRef = useRef<ScrollView>(null);
const isMultiline = contentHeight > SINGLE_LINE_HEIGHT + 4;
const handleContentSizeChange = useCallback(
(e: { nativeEvent: { contentSize: { height: number } } }) => {
const newHeight = e.nativeEvent.contentSize.height;
if (newHeight !== contentHeight) {
LayoutAnimation.configureNext(
LayoutAnimation.create(150, "easeInEaseOut", "opacity")
);
setContentHeight(newHeight);
}
},
[contentHeight]
);
function handleSend() {
const trimmed = input.trim();
if (!trimmed) return;
setMessages((prev) => [
...prev,
{ id: String(Date.now()), role: "user", content: trimmed },
]);
setInput("");
LayoutAnimation.configureNext(
LayoutAnimation.create(150, "easeInEaseOut", "opacity")
);
setContentHeight(SINGLE_LINE_HEIGHT);
setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 100);
}
const canSend = input.trim().length > 0;
return (
<SafeAreaView className="flex-1 bg-background">
<KeyboardAvoidingView
className="flex-1"
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
{/* Header */}
<View className="px-4 py-3">
<Text className="text-lg font-semibold text-foreground">Multica</Text>
<Text className="text-xs text-muted-foreground">Agent connected</Text>
</View>
{/* Messages */}
<ScrollView
ref={scrollRef}
className="flex-1"
contentContainerClassName="px-4 py-2 gap-5"
keyboardShouldPersistTaps="handled"
>
{messages.map((msg) =>
msg.role === "user" ? (
<View key={msg.id} className="flex-row justify-end">
<View className="max-w-[80%] rounded-2xl rounded-br-sm bg-muted px-4 py-2.5">
<Text className="text-[15px] leading-[22px] text-foreground">
{msg.content}
</Text>
</View>
</View>
) : (
<View key={msg.id}>
<Text className="text-[15px] leading-[22px] text-foreground">
{msg.content}
</Text>
</View>
)
)}
</ScrollView>
{/* Input bar */}
<View className="px-3 pb-1 pt-2">
<View
className={`flex-row border border-border bg-card pl-4 pr-1.5 py-1.5 ${
isMultiline ? "items-end rounded-2xl" : "items-center rounded-full"
}`}
>
<TextInput
className="flex-1 border-0 bg-transparent text-[15px] leading-5 text-foreground"
placeholder="Type a message..."
placeholderTextColor="hsl(240.1, 4.4%, 46.3%)"
value={input}
onChangeText={setInput}
onSubmitEditing={handleSend}
onContentSizeChange={handleContentSizeChange}
multiline
scrollEnabled={contentHeight >= INPUT_MAX_HEIGHT}
textAlignVertical="top"
selectionColor="hsl(243.5, 75.2%, 58.5%)"
underlineColorAndroid="transparent"
style={{
maxHeight: INPUT_MAX_HEIGHT,
paddingTop: 0,
paddingBottom: 0,
// @ts-ignore web-only: remove browser default outline/border
outline: "none",
borderWidth: 0,
boxShadow: "none",
}}
/>
<Pressable
onPress={handleSend}
disabled={!canSend}
className="ml-1.5 h-8 w-8 items-center justify-center rounded-full bg-primary"
style={{ opacity: canSend ? 1 : 0.5 }}
>
<HugeiconsIcon
icon={ArrowUp01Icon}
size={18}
color="hsl(225, 100%, 96.4%)"
strokeWidth={2.5}
/>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};

View file

@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "global.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View file

@ -0,0 +1,108 @@
import { TextClassContext } from '@/components/ui/text';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
import { Platform, Pressable } from 'react-native';
const buttonVariants = cva(
cn(
'group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none',
Platform.select({
web: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
})
),
{
variants: {
variant: {
default: cn(
'bg-primary active:bg-primary/90 shadow-sm shadow-black/5',
Platform.select({ web: 'hover:bg-primary/90' })
),
destructive: cn(
'bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm shadow-black/5',
Platform.select({
web: 'hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
})
),
outline: cn(
'border-border bg-background active:bg-accent dark:bg-input/30 dark:border-input dark:active:bg-input/50 border shadow-sm shadow-black/5',
Platform.select({
web: 'hover:bg-accent dark:hover:bg-input/50',
})
),
secondary: cn(
'bg-secondary active:bg-secondary/80 shadow-sm shadow-black/5',
Platform.select({ web: 'hover:bg-secondary/80' })
),
ghost: cn(
'active:bg-accent dark:active:bg-accent/50',
Platform.select({ web: 'hover:bg-accent dark:hover:bg-accent/50' })
),
link: '',
},
size: {
default: cn('h-10 px-4 py-2 sm:h-9', Platform.select({ web: 'has-[>svg]:px-3' })),
sm: cn('h-9 gap-1.5 rounded-md px-3 sm:h-8', Platform.select({ web: 'has-[>svg]:px-2.5' })),
lg: cn('h-11 rounded-md px-6 sm:h-10', Platform.select({ web: 'has-[>svg]:px-4' })),
icon: 'h-10 w-10 sm:h-9 sm:w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
const buttonTextVariants = cva(
cn(
'text-foreground text-sm font-medium',
Platform.select({ web: 'pointer-events-none transition-colors' })
),
{
variants: {
variant: {
default: 'text-primary-foreground',
destructive: 'text-white',
outline: cn(
'group-active:text-accent-foreground',
Platform.select({ web: 'group-hover:text-accent-foreground' })
),
secondary: 'text-secondary-foreground',
ghost: 'group-active:text-accent-foreground',
link: cn(
'text-primary group-active:underline',
Platform.select({ web: 'underline-offset-4 hover:underline group-hover:underline' })
),
},
size: {
default: '',
sm: '',
lg: '',
icon: '',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
type ButtonProps = React.ComponentProps<typeof Pressable> &
React.RefAttributes<typeof Pressable> &
VariantProps<typeof buttonVariants>;
function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
<Pressable
className={cn(props.disabled && 'opacity-50', buttonVariants({ variant, size }), className)}
role="button"
{...props}
/>
</TextClassContext.Provider>
);
}
export { Button, buttonTextVariants, buttonVariants };
export type { ButtonProps };

View file

@ -0,0 +1,52 @@
import { Text, TextClassContext } from '@/components/ui/text';
import { cn } from '@/lib/utils';
import { View, type ViewProps } from 'react-native';
function Card({ className, ...props }: ViewProps & React.RefAttributes<View>) {
return (
<TextClassContext.Provider value="text-card-foreground">
<View
className={cn(
'bg-card border-border flex flex-col gap-6 rounded-xl border py-6 shadow-sm shadow-black/5',
className
)}
{...props}
/>
</TextClassContext.Provider>
);
}
function CardHeader({ className, ...props }: ViewProps & React.RefAttributes<View>) {
return <View className={cn('flex flex-col gap-1.5 px-6', className)} {...props} />;
}
function CardTitle({
className,
...props
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
return (
<Text
role="heading"
aria-level={3}
className={cn('font-semibold leading-none', className)}
{...props}
/>
);
}
function CardDescription({
className,
...props
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
return <Text className={cn('text-muted-foreground text-sm', className)} {...props} />;
}
function CardContent({ className, ...props }: ViewProps & React.RefAttributes<View>) {
return <View className={cn('px-6', className)} {...props} />;
}
function CardFooter({ className, ...props }: ViewProps & React.RefAttributes<View>) {
return <View className={cn('flex flex-row items-center px-6', className)} {...props} />;
}
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };

View file

@ -0,0 +1,45 @@
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});

View file

@ -0,0 +1,32 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}

View file

@ -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<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['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<TextStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}

View file

@ -0,0 +1,29 @@
import { cn } from '@/lib/utils';
import { Platform, TextInput, type TextInputProps } from 'react-native';
function Input({ className, ...props }: TextInputProps & React.RefAttributes<TextInput>) {
return (
<TextInput
className={cn(
'dark:bg-input/30 border-input bg-background text-foreground flex h-10 w-full min-w-0 flex-row items-center rounded-md border px-3 py-1 text-base leading-5 shadow-sm shadow-black/5 sm:h-9',
props.editable === false &&
cn(
'opacity-50',
Platform.select({ web: 'disabled:pointer-events-none disabled:cursor-not-allowed' })
),
Platform.select({
web: cn(
'placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground outline-none transition-[color,box-shadow] md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive'
),
native: 'placeholder:text-muted-foreground/50',
}),
className
)}
{...props}
/>
);
}
export { Input };

View file

@ -0,0 +1,89 @@
import { cn } from '@/lib/utils';
import * as Slot from '@rn-primitives/slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { Platform, Text as RNText, type Role } from 'react-native';
const textVariants = cva(
cn(
'text-foreground text-base',
Platform.select({
web: 'select-text',
})
),
{
variants: {
variant: {
default: '',
h1: cn(
'text-center text-4xl font-extrabold tracking-tight',
Platform.select({ web: 'scroll-m-20 text-balance' })
),
h2: cn(
'border-border border-b pb-2 text-3xl font-semibold tracking-tight',
Platform.select({ web: 'scroll-m-20 first:mt-0' })
),
h3: cn('text-2xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),
h4: cn('text-xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),
p: 'mt-3 leading-7 sm:mt-6',
blockquote: 'mt-4 border-l-2 pl-3 italic sm:mt-6 sm:pl-6',
code: cn(
'bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold'
),
lead: 'text-muted-foreground text-xl',
large: 'text-lg font-semibold',
small: 'text-sm font-medium leading-none',
muted: 'text-muted-foreground text-sm',
},
},
defaultVariants: {
variant: 'default',
},
}
);
type TextVariantProps = VariantProps<typeof textVariants>;
type TextVariant = NonNullable<TextVariantProps['variant']>;
const ROLE: Partial<Record<TextVariant, Role>> = {
h1: 'heading',
h2: 'heading',
h3: 'heading',
h4: 'heading',
blockquote: Platform.select({ web: 'blockquote' as Role }),
code: Platform.select({ web: 'code' as Role }),
};
const ARIA_LEVEL: Partial<Record<TextVariant, string>> = {
h1: '1',
h2: '2',
h3: '3',
h4: '4',
};
const TextClassContext = React.createContext<string | undefined>(undefined);
function Text({
className,
asChild = false,
variant = 'default',
...props
}: React.ComponentProps<typeof RNText> &
TextVariantProps &
React.RefAttributes<RNText> & {
asChild?: boolean;
}) {
const textClass = React.useContext(TextClassContext);
const Component = asChild ? Slot.Text : RNText;
return (
<Component
className={cn(textVariants({ variant }), textClass, className)}
role={variant ? ROLE[variant] : undefined}
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
{...props}
/>
);
}
export { Text, TextClassContext };

View file

@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
},
]);

61
apps/mobile/global.css Normal file
View file

@ -0,0 +1,61 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240.1 11.2% 4%;
--card: 0 0% 100%;
--card-foreground: 240.1 11.2% 4%;
--popover: 0 0% 100%;
--popover-foreground: 240.1 11.2% 4%;
--primary: 243.5 75.2% 58.5%;
--primary-foreground: 225 100% 96.4%;
--secondary: 240 3.5% 95.8%;
--secondary-foreground: 240 6% 10%;
--muted: 240 3.5% 95.8%;
--muted-foreground: 240.1 4.4% 46.3%;
--accent: 240 3.5% 95.8%;
--accent-foreground: 240 6% 10%;
--destructive: 357.2 100% 45.3%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240.1 5.7% 64.3%;
--radius: 0.625rem;
--chart-1: 229.8 91.2% 82.1%;
--chart-2: 234.6 90.8% 74.1%;
--chart-3: 238.2 82.8% 66.8%;
--chart-4: 243.5 75.2% 58.5%;
--chart-5: 243.8 56.7% 50.9%;
}
.dark:root {
--background: 240.1 11.2% 4%;
--foreground: 180 0% 98%;
--card: 240 6% 10%;
--card-foreground: 180 0% 98%;
--popover: 240 6% 10%;
--popover-foreground: 180 0% 98%;
--primary: 238.2 82.8% 66.8%;
--primary-foreground: 225 100% 96.4%;
--secondary: 240 4% 15.9%;
--secondary-foreground: 180 0% 98%;
--muted: 240 4% 15.9%;
--muted-foreground: 240.1 5.7% 64.3%;
--accent: 240 4% 15.9%;
--accent-foreground: 180 0% 98%;
--destructive: 358.7 100% 69.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 5% 11%;
--input: 240 4% 15.5%;
--ring: 240.1 4.4% 46.3%;
--radius: 0.625rem;
--chart-1: 229.8 91.2% 82.1%;
--chart-2: 234.6 90.8% 74.1%;
--chart-3: 238.2 82.8% 66.8%;
--chart-4: 243.5 75.2% 58.5%;
--chart-5: 243.8 56.7% 50.9%;
}
}

71
apps/mobile/lib/theme.ts Normal file
View file

@ -0,0 +1,71 @@
import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native";
export const THEME = {
light: {
background: "hsl(0, 0%, 100%)",
foreground: "hsl(240.1, 11.2%, 4%)",
card: "hsl(0, 0%, 100%)",
cardForeground: "hsl(240.1, 11.2%, 4%)",
popover: "hsl(0, 0%, 100%)",
popoverForeground: "hsl(240.1, 11.2%, 4%)",
primary: "hsl(243.5, 75.2%, 58.5%)",
primaryForeground: "hsl(225, 100%, 96.4%)",
secondary: "hsl(240, 3.5%, 95.8%)",
secondaryForeground: "hsl(240, 6%, 10%)",
muted: "hsl(240, 3.5%, 95.8%)",
mutedForeground: "hsl(240.1, 4.4%, 46.3%)",
accent: "hsl(240, 3.5%, 95.8%)",
accentForeground: "hsl(240, 6%, 10%)",
destructive: "hsl(357.2, 100%, 45.3%)",
border: "hsl(240, 5.9%, 90%)",
input: "hsl(240, 5.9%, 90%)",
ring: "hsl(240.1, 5.7%, 64.3%)",
radius: "0.625rem",
},
dark: {
background: "hsl(240.1, 11.2%, 4%)",
foreground: "hsl(180, 0%, 98%)",
card: "hsl(240, 6%, 10%)",
cardForeground: "hsl(180, 0%, 98%)",
popover: "hsl(240, 6%, 10%)",
popoverForeground: "hsl(180, 0%, 98%)",
primary: "hsl(238.2, 82.8%, 66.8%)",
primaryForeground: "hsl(225, 100%, 96.4%)",
secondary: "hsl(240, 4%, 15.9%)",
secondaryForeground: "hsl(180, 0%, 98%)",
muted: "hsl(240, 4%, 15.9%)",
mutedForeground: "hsl(240.1, 5.7%, 64.3%)",
accent: "hsl(240, 4%, 15.9%)",
accentForeground: "hsl(180, 0%, 98%)",
destructive: "hsl(358.7, 100%, 69.6%)",
border: "hsl(240, 5%, 11%)",
input: "hsl(240, 4%, 15.5%)",
ring: "hsl(240.1, 4.4%, 46.3%)",
radius: "0.625rem",
},
};
export const NAV_THEME: Record<"light" | "dark", Theme> = {
light: {
...DefaultTheme,
colors: {
background: THEME.light.background,
border: THEME.light.border,
card: THEME.light.card,
notification: THEME.light.destructive,
primary: THEME.light.primary,
text: THEME.light.foreground,
},
},
dark: {
...DarkTheme,
colors: {
background: THEME.dark.background,
border: THEME.dark.border,
card: THEME.dark.card,
notification: THEME.dark.destructive,
primary: THEME.dark.primary,
text: THEME.dark.foreground,
},
},
};

6
apps/mobile/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View file

@ -0,0 +1,6 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: "./global.css", inlineRem: 16 });

1
apps/mobile/nativewind-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="nativewind/types" />

60
apps/mobile/package.json Normal file
View file

@ -0,0 +1,60 @@
{
"name": "@multica/mobile",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react-native": "^1.0.11",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@rn-primitives/portal": "^1.3.0",
"@rn-primitives/slot": "^1.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"lucide-react-native": "^0.563.0",
"nativewind": "^4.2.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-css-interop": "^0.2.1",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/react": "~19.1.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"tailwindcss": "3.4.17",
"typescript": "~5.9.2"
},
"private": true
}

View file

@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
const exampleDir = "app-example";
const newAppDir = "app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Edit app/index.tsx to edit this screen.</Text>
</View>
);
}
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);

View file

@ -0,0 +1,59 @@
const { hairlineWidth } = require("nativewind/theme");
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
borderWidth: {
hairline: hairlineWidth(),
},
},
},
future: {
hoverOnlyWhenSupported: true,
},
plugins: [require("tailwindcss-animate")],
};

18
apps/mobile/tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
]
}

View file

@ -0,0 +1,234 @@
# App Store Submission Guide
Complete guide for publishing the Expo React Native app to Apple App Store and Google Play Store.
## 1. Prerequisites
### Accounts & Fees
| Platform | Cost | Notes |
|----------|------|-------|
| Apple Developer Program | $99/year | Required for App Store distribution |
| Google Play Console | $25 one-time | Developer registration |
| Expo Account | Free (paid plans available) | Required for EAS Build/Submit |
- Apple Developer account review: 1-2 days
- Google Play developer account review: days to weeks
### Tools
```bash
npm install -g eas-cli
eas login
eas whoami # verify login
```
## 2. Project Configuration
### Initialize EAS
```bash
eas build:configure
```
Generates `eas.json` with three build profiles: `development`, `preview`, `production`.
### Key `app.json` / `app.config.ts` Fields
```jsonc
{
"name": "Multica",
"slug": "multica",
"version": "1.0.0",
"ios": {
"bundleIdentifier": "com.multica.app",
"buildNumber": "1" // increment on each submission
},
"android": {
"package": "com.multica.app",
"versionCode": 1 // increment on each submission
},
"icon": "./assets/icon.png", // 1024x1024 PNG
"splash": {
"image": "./assets/splash.png"
}
}
```
## 3. App Signing & Credentials
### iOS
- EAS auto-manages credentials (recommended): Distribution Certificate + Provisioning Profile
- Or create manually in Apple Developer Portal
### Android
- EAS auto-generates Keystore (recommended), stored securely on EAS servers
- **Back up Keystore** — losing it means you cannot update the published app
- Play Store requires AAB (Android App Bundle) format
## 4. Production Build
```bash
# iOS
eas build --platform ios --profile production
# Android
eas build --platform android --profile production
# Both
eas build --platform all --profile production
```
Builds run in Expo cloud — no local Xcode or Android Studio needed.
## 5. Store Listing Preparation
### Required for Both Platforms
#### Privacy Policy
- **Mandatory** — must be a publicly accessible URL
- Must clearly state:
- What data the app collects and how
- Whether data is shared with third parties
- Data retention and deletion policies
- How users can request data deletion
- **2025 rule**: If data is sent to third-party AI, must disclose explicitly and obtain user consent
- Tools: Termly, PrivacyPolicies.com, or custom page
#### App Screenshots
- **iOS**: Multiple sizes required (6.7", 6.5", 5.5" iPhone + iPad)
- **Android**: 2-8 screenshots
- Must accurately reflect current app interface
#### App Icon
- 1024x1024 high-resolution PNG
- No alpha/transparency for iOS
#### App Description
- Short description (≤80 chars for Google Play)
- Full description
#### Support URL
- A link where users can get help
#### Account Deletion
- If the app supports registration, users **must** be able to delete their account and data in-app
- Both Apple and Google require this
### Apple App Store Connect — Additional Requirements
| Item | Details |
|------|---------|
| Privacy Nutrition Labels | Fill out data collection practices per category in App Store Connect |
| App Review Information | Reviewer contact info, demo/test account credentials |
| Content Rating | Age classification |
| Export Compliance | Encryption usage declaration |
| Info.plist Permission Strings | Clear purpose description for each permission (camera, location, etc.) |
### Google Play Console — Additional Requirements
| Item | Details |
|------|---------|
| Data Safety Form | Detail data collection and sharing (required even if no data is collected) |
| Content Rating Questionnaire | IARC rating questionnaire |
| Target Audience | Declare if the app targets children |
| First Upload | Must be done manually via Play Console (Google Play API limitation) |
## 6. Submit to Stores
### Apple App Store
```bash
eas submit --platform ios
```
This uploads the build to **App Store Connect / TestFlight**. Then you must:
1. Log into App Store Connect
2. Select the uploaded build
3. Associate it with a version
4. Fill in all metadata, screenshots, privacy labels
5. Submit for App Review
### Google Play Store
```bash
eas submit --platform android
```
**First time**: Must upload AAB manually in Play Console.
After initial upload:
1. Navigate to Production → Create new release
2. Upload AAB or use the EAS-submitted build
3. Fill in description, screenshots, data safety form
4. Submit for review
### Auto-Submit (Optional)
```bash
eas build --platform all --profile production --auto-submit
```
## 7. App Review
| | Apple | Google |
|---|---|---|
| Review time | Typically 24-48 hours | Hours to 7 days |
| Common rejections | Incomplete features, misleading screenshots, missing privacy policy, unclear permission strings | Data safety form mismatch, policy violations |
| After rejection | Fix issues, resubmit | Fix issues, resubmit |
## 8. Post-Launch
### OTA Updates (No Re-Review Needed)
```bash
eas update --branch production
```
- Only for JS/asset-level changes
- Native code changes still require a new build + review
### CI/CD Automation
Create `.eas/workflows/build-and-submit.yml` to auto-build and submit on push to main.
### Google Service Account Key (for Automated Android Submissions)
1. Go to EAS dashboard → Credentials → Android
2. Click Application identifier → Service Credentials
3. Add Google Service Account Key
## 9. Checklist
- [ ] Register Apple Developer + Google Play Console accounts
- [ ] Configure `app.json` and `eas.json`
- [ ] Prepare app icon, splash screen, screenshots
- [ ] Write and host privacy policy URL
- [ ] Implement in-app account deletion (if registration exists)
- [ ] Add Info.plist permission descriptions (iOS)
- [ ] Run `eas build --platform all --profile production`
- [ ] Create app in App Store Connect, fill metadata + privacy labels
- [ ] Create app in Google Play Console, fill data safety form, manual first AAB upload
- [ ] `eas submit` or submit manually for review
- [ ] Wait for review approval → live
- [ ] Set up `eas update` for OTA updates
## References
- [Expo: Submit to App Stores](https://docs.expo.dev/deploy/submit-to-app-stores/)
- [Expo: EAS Submit](https://docs.expo.dev/submit/introduction/)
- [Expo: Build Your Project](https://docs.expo.dev/deploy/build-project/)
- [Expo: App Stores Best Practices](https://docs.expo.dev/distribution/app-stores/)
- [Apple App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
- [Apple App Privacy Details](https://developer.apple.com/app-store/app-privacy-details/)
- [Google Play Data Safety](https://support.google.com/googleplay/android-developer/answer/10787469)
- [Google Play Developer Policy Center](https://play.google/developer-content-policy/)

5752
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff