Compare commits

...

3 Commits

Author SHA1 Message Date
982e307482 chore: 커밋 누락 2026-01-23 11:15:01 +09:00
1c60a450b9 refactor: audiobook card 컬러 테마 수정 2026-01-23 11:14:49 +09:00
81897144c6 feat: 메인 화면 바텀 네비게이션 생성 2026-01-05 13:10:14 +09:00
13 changed files with 612 additions and 9251 deletions

1
.gitignore vendored
View File

@@ -41,3 +41,4 @@ yarn-error.*
/android
.env.*
.idea
package-lock.json

View File

@@ -27,6 +27,9 @@
},
"web": {
"favicon": "./assets/favicon.png"
}
},
"plugins": [
"@react-native-google-signin/google-signin"
]
}
}

9221
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,9 +19,12 @@
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-google-signin/google-signin": "^16.1.1",
"@react-navigation/bottom-tabs": "^7.9.0",
"@react-navigation/native": "^7.1.26",
"@react-navigation/native-stack": "^7.9.0",
"expo": "~54.0.30",
"expo-apple-authentication": "~8.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5",

View File

@@ -14,5 +14,14 @@ export type LoginData = {
export type LoginResponse = ApiResponse<LoginData>;
// 회원가입 api
export type SignUpRequest = { loginId: string; password: string };
export type SignUpResponse = { userId: number };
export type SignUpRequest = {
loginId: string;
password: string;
};
export type SignUpData = {
accessToken: string;
refreshToken: string;
};
export type SignUpResponse = ApiResponse<SignUpData>;

View File

@@ -0,0 +1,233 @@
import React from "react";
import { ActivityIndicator, Pressable, StyleSheet, View } from "react-native";
import { Theme } from "../../theme/theme";
import AppText from "../ui/AppText";
import Card from "../ui/Card";
export type AudioStatus = "QUEUED" | "GENERATING" | "READY" | "FAILED";
type Props = {
title: string;
meta: string;
status: AudioStatus;
progress?: number;
onPress?: () => void;
onRetry?: () => void;
};
const STATUS_UI: Record<
AudioStatus,
{ label: string; accent: string; badgeBg: string; badgeText: string }
> = {
QUEUED: {
label: "대기중",
accent: "#94a3b8",
badgeBg: "rgba(148,163,184,0.15)",
badgeText: "#64748b",
},
GENERATING: {
label: "생성중",
accent: "#60a5fa",
badgeBg: "rgba(96,165,250,0.15)",
badgeText: "#3b82f6",
},
READY: {
label: "완료",
accent: "#34d399",
badgeBg: "rgba(52,211,153,0.15)",
badgeText: "#10b981",
},
FAILED: {
label: "실패",
accent: "#fb7185",
badgeBg: "rgba(251,113,133,0.15)",
badgeText: "#e11d48",
},
};
export default function AudioBookCard({
title,
meta,
status,
progress,
onPress,
onRetry,
}: Props) {
const ui = STATUS_UI[status];
const canPress = status === "READY" && !onPress;
const showRetry = status === "FAILED" && !onRetry;
return (
<Card
onPress={canPress ? onPress : undefined}
disabled={!canPress}
accentColor={ui.accent}
wrapperStyle={styles.wrapper}
>
{/* Top row */}
<View style={styles.topRow}>
<View style={styles.titleBlock}>
<AppText variant="title" numberOfLines={1}>
{title}
</AppText>
<View style={{ height: Theme.Spacing.xs }} />
<AppText variant="muted" numberOfLines={1}>
{meta}
</AppText>
</View>
<View style={styles.rightBlock}>
<Badge text={ui.label} bg={ui.badgeBg} color={ui.badgeText} />
{status === "GENERATING" ? (
<View style={{ height: Theme.Spacing.sm }} />
) : (
<View style={{ height: Theme.Spacing.sm }} />
)}
{status === "GENERATING" ? (
<ActivityIndicator />
) : status === "READY" ? (
<PlayPill />
) : null}
</View>
</View>
{/* Middle: generating progress */}
{status === "GENERATING" ? (
<View style={styles.progressWrap}>
<View style={{ height: Theme.Spacing.sm }} />
<ProgressBar progress={progress} accent={ui.accent} />
<View style={{ height: Theme.Spacing.xs }} />
<AppText variant="muted">
{typeof progress === "number"
? `${Math.round(progress * 100)}% 진행`
: "작업 중…"}
</AppText>
</View>
) : null}
{/* Bottom: failed actions */}
{showRetry ? (
<View style={styles.bottomRow}>
<AppText variant="muted" style={{ flex: 1 }}>
. ?
</AppText>
<Pressable onPress={onRetry} style={styles.retryBtn}>
<AppText></AppText>
</Pressable>
</View>
) : null}
</Card>
);
}
function Badge({
text,
bg,
color,
}: {
text: string;
bg: string;
color: string;
}) {
return (
<View style={[styles.badge, { backgroundColor: bg }]}>
<AppText style={{ color }}>{text}</AppText>
</View>
);
}
function PlayPill() {
return (
<View style={styles.playPill}>
<AppText></AppText>
</View>
);
}
function ProgressBar({
progress,
accent,
}: {
progress?: number;
accent: string;
}) {
const pct =
typeof progress === "number"
? Math.max(0, Math.min(1, progress))
: undefined;
return (
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{
backgroundColor: accent,
width: pct == null ? "60%" : `${pct * 100}%`,
},
pct == null && { opacity: 0.55 },
]}
/>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
marginHorizontal: Theme.Spacing.lg,
marginTop: Theme.Spacing.md,
},
topRow: {
flexDirection: "row",
gap: Theme.Spacing.md,
alignItems: "flex-start",
},
titleBlock: {
flex: 1,
},
rightBlock: {
alignItems: "flex-end",
gap: Theme.Spacing.xs,
},
badge: {
paddingHorizontal: Theme.Spacing.sm,
paddingVertical: Theme.Spacing.xs,
borderRadius: 999,
},
playPill: {
paddingHorizontal: Theme.Spacing.md,
paddingVertical: Theme.Spacing.xs,
borderRadius: 999,
borderWidth: 1,
borderColor: Theme.Colors.border,
},
progressWrap: {
marginTop: Theme.Spacing.md,
},
progressTrack: {
height: 8,
borderRadius: 999,
backgroundColor: "rgba(148,163,184,0.18)",
overflow: "hidden",
},
progressFill: {
height: "100%",
borderRadius: 999,
},
bottomRow: {
marginTop: Theme.Spacing.md,
flexDirection: "row",
alignItems: "center",
gap: Theme.Spacing.sm,
},
retryBtn: {
paddingHorizontal: Theme.Spacing.md,
paddingVertical: Theme.Spacing.xs,
borderRadius: 999,
borderWidth: 1,
borderColor: Theme.Colors.border,
},
});

131
src/components/ui/Card.tsx Normal file
View File

@@ -0,0 +1,131 @@
import React from "react";
import {
Pressable,
StyleSheet,
View,
type StyleProp,
type ViewProps,
type ViewStyle,
} from "react-native";
import { Theme } from "../../theme/theme";
type Props = Omit<ViewProps, "style"> & {
style?: StyleProp<ViewStyle>;
wrapperStyle?: StyleProp<ViewStyle>;
onPress?: () => void;
disabled?: boolean;
accentColor?: string;
accentWidth?: number;
};
export default function Card({
style,
wrapperStyle,
onPress,
disabled,
accentColor,
accentWidth = 4,
children,
...props
}: Props) {
const hasAccent = !!accentColor;
const contentPad: ViewStyle | undefined = hasAccent
? { paddingLeft: Theme.Spacing.lg + accentWidth + Theme.Spacing.sm }
: undefined;
const Inner = (pressed?: boolean) => (
<View
{...props}
style={[styles.inner, contentPad, disabled && styles.disabled, style]}
>
{hasAccent && (
<View
pointerEvents="none"
style={[
styles.accent,
{ backgroundColor: accentColor, width: accentWidth },
]}
/>
)}
{pressed && !disabled && (
<View pointerEvents="none" style={styles.pressOverlay} />
)}
{children}
</View>
);
if (onPress) {
return (
<View style={[styles.shadowWrap, wrapperStyle]}>
<Pressable
onPress={onPress}
disabled={disabled}
style={({ pressed }) => [
styles.pressableClip,
pressed && !disabled && styles.pressed,
]}
android_ripple={{ color: "rgba(0,0,0,0.06)" }}
>
{({ pressed }) => Inner(pressed)}
</Pressable>
</View>
);
}
return (
<View style={[styles.shadowWrap, wrapperStyle]}>
<View style={styles.pressableClip}>{Inner(false)}</View>
</View>
);
}
const styles = StyleSheet.create({
shadowWrap: {
borderRadius: Theme.Radius.lg,
shadowColor: "#0B1220",
shadowOpacity: 0.1,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
elevation: 3,
},
// 안쪽: 라운드/리플/오버레이 클립 담당
pressableClip: {
borderRadius: Theme.Radius.lg,
overflow: "hidden",
},
// 실제 카드 표면
inner: {
backgroundColor: "#FFFFFF",
padding: Theme.Spacing.lg,
borderWidth: 1,
borderColor: "rgba(15, 23, 42, 0.10)", // 지금보다 살짝 진하게
},
accent: {
position: "absolute",
left: 0,
top: 0,
bottom: 0,
},
pressOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.04)",
},
pressed: {
transform: [{ translateY: 1 }],
},
disabled: {
opacity: 0.55,
},
});

View File

@@ -1,9 +1,9 @@
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import React from "react";
import HomeScreen from "../screens/HomeScreen";
import AppTabs from "./AppTabs";
export type AppStackParamList = {
Home: undefined;
MainTabs: undefined;
};
const Stack = createNativeStackNavigator<AppStackParamList>();
@@ -11,7 +11,7 @@ const Stack = createNativeStackNavigator<AppStackParamList>();
export default function AppStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="MainTabs" component={AppTabs} />
</Stack.Navigator>
);
}

View File

@@ -0,0 +1,30 @@
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import React from "react";
import HomeScreen from "../screens/HomeScreen";
import { Colors } from "../theme/colors";
export type AppTabParamList = {
Library: undefined;
Home: undefined;
Settings: undefined;
};
const Tab = createBottomTabNavigator<AppTabParamList>();
export default function AppTabs() {
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarStyle: {
backgroundColor: Colors.surface,
borderTopColor: Colors.border,
},
tabBarActiveTintColor: Colors.text,
tabBarInactiveTintColor: Colors.mutedText,
}}
>
<Tab.Screen name="Home" component={HomeScreen} />
</Tab.Navigator>
);
}

View File

@@ -1,20 +1,27 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Button, StyleSheet, View } from "react-native";
import AppText from "../components/ui/AppText";
import AudioBookCard from "../components/ui/AudioBookCard";
import Card from "../components/ui/Card";
import { useAuth } from "../store/auth";
import { Colors } from "../theme/colors";
export default function HomeScreen() {
const { signOut } = useAuth();
return (
<View style={styles.container}>
<Button
title="Debug Storage"
onPress={async () => {
const keys = await AsyncStorage.getAllKeys();
const entries = await AsyncStorage.multiGet(keys);
console.log(entries);
}}
/>
<Card>
<AppText>feafwea</AppText>
<AppText>feafwea</AppText>
</Card>
<AudioBookCard
meta="ff"
title="fefe"
status="READY"
progress={0.91}
></AudioBookCard>
<Button title="logout" onPress={() => signOut()}></Button>
<AppText>fewfeaw</AppText>
<AppText>fewfeaw</AppText>
</View>
);
}
@@ -22,8 +29,15 @@ export default function HomeScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "red",
backgroundColor: Colors.bg,
alignContent: "center",
justifyContent: "center",
padding: 8,
},
row: {
flexDirection: "row",
gap: 12,
justifyContent: "center",
alignItems: "center",
},
});

View File

@@ -0,0 +1,5 @@
import { View } from "react-native";
export default function LibraryScreen() {
return <View>Library</View>;
}

View File

@@ -1,8 +1,10 @@
import { AntDesign, Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import React, { useState } from "react";
import { Pressable, StyleSheet, View } from "react-native";
import { Platform, Pressable, StyleSheet, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import AppText from "../components/ui/AppText";
import Button from "../components/ui/Button";
import Input from "../components/ui/Input";
@@ -11,13 +13,26 @@ import { useAuth } from "../store/auth";
import { Theme } from "../theme/theme";
export default function LoginScreen() {
const { signIn } = useAuth();
const { signIn /*, signInWithGoogle, signInWithApple */ } = useAuth();
const [loginId, setLoginId] = useState("");
const [password, setPassword] = useState("");
const canSubmit = loginId.trim().length > 0 && password.length > 0;
const navigation =
useNavigation<NativeStackNavigationProp<AuthStackParamList>>();
const onGooglePress = () => {
// TODO: 구현 후 연결
// signInWithGoogle();
console.log("Google login");
};
const onApplePress = () => {
// TODO: 구현 후 연결
// signInWithApple();
console.log("Apple login");
};
return (
<SafeAreaView style={styles.safe}>
<View style={styles.container}>
@@ -57,10 +72,53 @@ export default function LoginScreen() {
<Button
title="Continue"
onPress={() => {
signIn(loginId, password);
signIn(loginId.trim(), password);
}}
/>
{/* divider */}
<View style={{ height: Theme.Spacing.md }} />
<View style={styles.dividerRow}>
<View style={styles.dividerLine} />
<AppText variant="muted" style={styles.dividerText}>
or
</AppText>
<View style={styles.dividerLine} />
</View>
{/* Google */}
<Pressable
onPress={onGooglePress}
style={({ pressed }) => [
styles.socialBtn,
pressed && styles.socialBtnPressed,
]}
>
<View style={styles.socialRow}>
<AntDesign name="google" size={18} color={Theme.Colors.text} />
<AppText style={styles.socialText}>Continue with Google</AppText>
</View>
</Pressable>
{/* Apple (iOS only) */}
{Platform.OS === "ios" && (
<Pressable
onPress={onApplePress}
style={({ pressed }) => [
styles.socialBtn,
styles.appleBtn,
pressed && styles.appleBtnPressed,
]}
>
<View style={styles.socialRow}>
<Ionicons name="logo-apple" size={20} color="#fff" />
<AppText style={[styles.socialText, styles.appleText]}>
Continue with Apple
</AppText>
</View>
</Pressable>
)}
<View style={{ height: Theme.Spacing.md }} />
<Pressable
@@ -89,6 +147,55 @@ const styles = StyleSheet.create({
justifyContent: "center",
gap: Theme.Spacing.sm,
},
dividerRow: {
flexDirection: "row",
alignItems: "center",
gap: Theme.Spacing.sm,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: Theme.Colors.border,
opacity: 0.7,
},
dividerText: {
textAlign: "center",
},
socialBtn: {
borderWidth: 1,
borderColor: Theme.Colors.border,
borderRadius: Theme.Radius.lg,
paddingVertical: Theme.Spacing.md,
paddingHorizontal: Theme.Spacing.lg,
backgroundColor: Theme.Colors.card,
},
socialBtnPressed: {
opacity: 0.9,
transform: [{ scale: 0.99 }],
},
socialRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: Theme.Spacing.sm,
},
socialText: {
fontWeight: Theme.FontWeight.semibold,
},
appleBtn: {
backgroundColor: "#000",
borderColor: "#000",
},
appleBtnPressed: {
opacity: 0.92,
},
appleText: {
color: "#fff",
},
signupLine: {
textAlign: "center",
},

View File

@@ -1,22 +1,68 @@
// export const Colors = {
// // base
// bg: "#0B1220", // 깊은 네이비
// surface: "#111B2E", // 기본 표면
// card: "#162442", // 카드/리스트 아이템
// // text
// text: "#EAF0FF",
// mutedText: "#A8B3CF",
// placeholder: "#6F7AA3",
// // brand
// primary: "#2DD4BF", // 청록 포인트 (오디오앱에 잘 맞음)
// primaryPressed: "#14B8A6",
// // status
// danger: "#F87171",
// // lines
// border: "#223055",
// inputBg: "#0E1730",
// } as const;
// export const Colors = {
// // base
// bg: "#0B0B14",
// surface: "#121225",
// card: "#1A1B33",
// // text
// text: "#F1F2FF",
// mutedText: "#B0B3D6",
// placeholder: "#7A7FA8",
// // brand
// primary: "#8B5CF6",
// primaryPressed: "#7C3AED",
// // status
// danger: "#FB7185",
// // lines
// border: "#2A2D52",
// inputBg: "#0F1022",
// } as const;
export const Colors = {
// base
bg: "#0B1220",
surface: "#111827",
card: "#0F172A",
bg: "#F7F4EE",
surface: "#FFFFFF",
card: "#FFFFFF",
// text
text: "#FFFFFF",
mutedText: "#9CA3AF",
placeholder: "#6B7280",
text: "#1F2937",
mutedText: "#6B7280",
placeholder: "#9CA3AF",
// brand
primary: "#4F46E5",
primaryPressed: "#4338CA",
primary: "#0EA5A4", // 청록 포인트
primaryPressed: "#0B8B8A",
// status
danger: "#EF4444",
danger: "#DC2626",
// lines
border: "#22304A",
inputBg: "#0B1220",
border: "#E6E0D6",
inputBg: "#F1EEE7",
} as const;