diff --git a/src/components/ui/AudioBookCard.tsx b/src/components/ui/AudioBookCard.tsx new file mode 100644 index 0000000..9462894 --- /dev/null +++ b/src/components/ui/AudioBookCard.tsx @@ -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 ( + + {/* Top row */} + + + + {title} + + + + {meta} + + + + + + + {status === "GENERATING" ? ( + + ) : ( + + )} + + {status === "GENERATING" ? ( + + ) : status === "READY" ? ( + + ) : null} + + + + {/* Middle: generating progress */} + {status === "GENERATING" ? ( + + + + + + {typeof progress === "number" + ? `${Math.round(progress * 100)}% 진행` + : "작업 중…"} + + + ) : null} + + {/* Bottom: failed actions */} + {showRetry ? ( + + + 생성에 실패했어요. 다시 시도할까요? + + + 재시도 + + + ) : null} + + ); +} + +function Badge({ + text, + bg, + color, +}: { + text: string; + bg: string; + color: string; +}) { + return ( + + {text} + + ); +} + +function PlayPill() { + return ( + + ▶︎ + + ); +} + +function ProgressBar({ + progress, + accent, +}: { + progress?: number; + accent: string; +}) { + const pct = + typeof progress === "number" + ? Math.max(0, Math.min(1, progress)) + : undefined; + + return ( + + + + ); +} + +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, + }, +}); diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx new file mode 100644 index 0000000..d2a38dc --- /dev/null +++ b/src/components/ui/Card.tsx @@ -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 & { + style?: StyleProp; + wrapperStyle?: StyleProp; + 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) => ( + + {hasAccent && ( + + )} + + {pressed && !disabled && ( + + )} + + {children} + + ); + + if (onPress) { + return ( + + [ + styles.pressableClip, + pressed && !disabled && styles.pressed, + ]} + android_ripple={{ color: "rgba(0,0,0,0.06)" }} + > + {({ pressed }) => Inner(pressed)} + + + ); + } + + return ( + + {Inner(false)} + + ); +} + +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, + }, +}); diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 0361f85..8d4d287 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -1,5 +1,7 @@ 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"; @@ -7,6 +9,16 @@ export default function HomeScreen() { const { signOut } = useAuth(); return ( + + feafwea + feafwea + + fewfeaw fewfeaw @@ -20,6 +32,7 @@ const styles = StyleSheet.create({ backgroundColor: Colors.bg, alignContent: "center", justifyContent: "center", + padding: 8, }, row: { flexDirection: "row", diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index b5912f7..2c26e74 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -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>(); + const onGooglePress = () => { + // TODO: 구현 후 연결 + // signInWithGoogle(); + console.log("Google login"); + }; + + const onApplePress = () => { + // TODO: 구현 후 연결 + // signInWithApple(); + console.log("Apple login"); + }; + return ( @@ -57,10 +72,53 @@ export default function LoginScreen() {