feat(mobile): implement onboarding screen (#3029)
* feat(mobile): implement onboarding screen * refactor(mobile): extract avatar setting functionality into a separate utility function * feat(mobile): integrate StepInterests into onboarding * feat(mobile): add preset feeds configuration for onboarding * feat(mobile): add onboarding completion and welcome steps * chore: tweak styles * fix: icon * chore: add personalization prompt to onboarding preferences * chore: enhance onboarding with improved layout * chore: clean code * refactor: compatible with modal for EditProfileModal
This commit is contained in:
parent
d54535dc45
commit
2d92a4f59d
|
|
@ -0,0 +1,26 @@
|
|||
import * as React from "react"
|
||||
import Svg, { Path } from "react-native-svg"
|
||||
|
||||
interface ListCheck2CuteReIconProps {
|
||||
width?: number
|
||||
height?: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
export const ListCheck2CuteReIcon = ({
|
||||
width = 24,
|
||||
height = 24,
|
||||
color = "#10161F",
|
||||
}: ListCheck2CuteReIconProps) => {
|
||||
return (
|
||||
<Svg width={width} height={height} fill="none" viewBox="0 0 24 24">
|
||||
<Path
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5h9m-9 7h9m-9 7h9M7.945 3.72c-.941.725-1.754 1.53-2.475 2.475A4.225 4.225 0 0 0 4.056 4.78m0 7c.592.373 1.05.818 1.414 1.415a13.22 13.22 0 0 1 2.475-2.475m-3.89 8.06c.593.373 1.051.817 1.415 1.415a13.22 13.22 0 0 1 2.475-2.475"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import * as React from "react"
|
||||
import Svg, { Path } from "react-native-svg"
|
||||
|
||||
interface Shuffle2CuteReIconProps {
|
||||
width?: number
|
||||
height?: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
export const Shuffle2CuteReIcon = ({
|
||||
width = 24,
|
||||
height = 24,
|
||||
color = "#10161F",
|
||||
}: Shuffle2CuteReIconProps) => {
|
||||
return (
|
||||
<Svg width={width} height={height} fill="none" viewBox="0 0 24 24">
|
||||
<Path
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={2}
|
||||
d="M4 7v0c1.69 0 2.535 0 3.273.308a4 4 0 0 1 .837.473c.644.475 1.078 1.2 1.948 2.649l1.884 3.14c.87 1.45 1.304 2.174 1.948 2.649.259.19.54.35.836.473C15.466 17 16.31 17 18 17v0m0-10h-1.084c-.632 0-.948 0-1.241.044a4 4 0 0 0-2.5 1.415c-.188.229-.35.5-.675 1.041v0M4 17h1.085c.631 0 .947 0 1.24-.044a4 4 0 0 0 2.5-1.415c.188-.229.35-.5.675-1.041"
|
||||
/>
|
||||
<Path
|
||||
fill={color}
|
||||
fillRule="evenodd"
|
||||
d="M17.847 4.507c-.503-.263-1.102.127-1.15.748-.032.407-.064.994-.064 1.708 0 .748.035 1.357.068 1.764.048.58.592.914 1.09.654.33-.171.806-.438 1.376-.808.57-.37 1.013-.701 1.31-.936.448-.355.465-1.051.042-1.388a17.102 17.102 0 0 0-1.325-.95c-.55-.358-1.019-.62-1.347-.792M17.847 14.617c-.503-.263-1.102.127-1.15.748-.032.406-.064.994-.064 1.708 0 .747.035 1.357.068 1.764.048.58.592.914 1.09.654.33-.171.806-.438 1.376-.808.57-.37 1.013-.701 1.31-.936.448-.355.465-1.051.042-1.388a17.13 17.13 0 0 0-1.325-.95c-.55-.358-1.019-.62-1.347-.792"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { setGeneralSetting, useGeneralSettingKey } from "@/src/atoms/settings/general"
|
||||
|
||||
type ReadingBehavior = "radical" | "balanced" | "conservative"
|
||||
|
||||
export const useReadingBehavior = () => {
|
||||
const markAsReadWhenScrolling = useGeneralSettingKey("scrollMarkUnread")
|
||||
const markAsReadWhenInView = useGeneralSettingKey("renderMarkUnread")
|
||||
|
||||
const behavior: ReadingBehavior =
|
||||
markAsReadWhenInView && markAsReadWhenScrolling
|
||||
? "radical"
|
||||
: !markAsReadWhenInView && !markAsReadWhenScrolling
|
||||
? "conservative"
|
||||
: "balanced"
|
||||
|
||||
const updateSettings = (behavior: ReadingBehavior) => {
|
||||
switch (behavior) {
|
||||
case "radical": {
|
||||
setGeneralSetting("scrollMarkUnread", true)
|
||||
setGeneralSetting("renderMarkUnread", true)
|
||||
break
|
||||
}
|
||||
case "balanced": {
|
||||
setGeneralSetting("scrollMarkUnread", true)
|
||||
setGeneralSetting("renderMarkUnread", false)
|
||||
break
|
||||
}
|
||||
case "conservative": {
|
||||
setGeneralSetting("scrollMarkUnread", false)
|
||||
setGeneralSetting("renderMarkUnread", false)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
behavior,
|
||||
markAsReadWhenScrolling,
|
||||
markAsReadWhenInView,
|
||||
updateSettings,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
|
||||
export type PresetFeedConfig = {
|
||||
title: string
|
||||
feedId: string
|
||||
url: string
|
||||
view: FeedViewType
|
||||
}
|
||||
|
||||
export const presetFeeds: PresetFeedConfig[] = [
|
||||
{
|
||||
feedId: "41358761177015296",
|
||||
title: "知乎热榜 - 全站",
|
||||
url: "rsshub://zhihu/hot/total",
|
||||
view: FeedViewType.Articles,
|
||||
},
|
||||
{
|
||||
feedId: "100020530265058357",
|
||||
title: "阮一峰的网络日志",
|
||||
url: "https://feeds.feedburner.com/ruanyifeng",
|
||||
view: FeedViewType.Articles,
|
||||
},
|
||||
|
||||
{
|
||||
feedId: "41358830592746496",
|
||||
title: "微博热搜榜",
|
||||
url: "rsshub://weibo/search/hot",
|
||||
view: FeedViewType.SocialMedia,
|
||||
},
|
||||
{
|
||||
feedId: "100411504863520768",
|
||||
title: "Twitter @Elon Musk",
|
||||
url: "rsshub://twitter/user/elonmusk",
|
||||
view: FeedViewType.SocialMedia,
|
||||
},
|
||||
{
|
||||
feedId: "41324816676184077",
|
||||
title: "Twitter @DIŸgöd ☀️",
|
||||
url: "rsshub://twitter/user/DIYgod",
|
||||
view: FeedViewType.SocialMedia,
|
||||
},
|
||||
|
||||
{
|
||||
feedId: "78806242632741888",
|
||||
title: "bilibili 排行榜-全站",
|
||||
url: "rsshub://bilibili/ranking/0",
|
||||
view: FeedViewType.Videos,
|
||||
},
|
||||
|
||||
{
|
||||
feedId: "60338304723722240",
|
||||
title: "实时财经快讯 - FastBull",
|
||||
url: "rsshub://fastbull/express-news",
|
||||
view: FeedViewType.Articles,
|
||||
},
|
||||
{
|
||||
feedId: "55611390687386624",
|
||||
title: "格隆汇快讯-7x24小时市场快讯-财经市场热点",
|
||||
url: "rsshub://gelonghui/live",
|
||||
view: FeedViewType.Articles,
|
||||
},
|
||||
{
|
||||
feedId: "49375919416104960",
|
||||
title: "深潮TechFlow - 快讯",
|
||||
url: "rsshub://techflowpost/express",
|
||||
view: FeedViewType.Articles,
|
||||
},
|
||||
{
|
||||
feedId: "55982073122828305",
|
||||
title: "TED Talks Daily",
|
||||
url: "https://feeds.acast.com/public/shows/67587e77c705e441797aff96",
|
||||
view: FeedViewType.Articles,
|
||||
},
|
||||
{
|
||||
feedId: "72541715399995392",
|
||||
title: "TheBlockBeats - 快讯",
|
||||
url: "rsshub://theblockbeats/newsflash/0",
|
||||
view: FeedViewType.Articles,
|
||||
},
|
||||
{
|
||||
feedId: "100184911354754055",
|
||||
title: "小Lin说",
|
||||
url: "https://www.youtube.com/feeds/videos.xml?channel_id=UCilwQlk62k1z7aUEZPOB6yw",
|
||||
view: FeedViewType.Videos,
|
||||
},
|
||||
{
|
||||
feedId: "100185810923910148",
|
||||
title: "张小珺Jùn|商业访谈录",
|
||||
url: "https://feed.xyzfm.space/dk4yh3pkpjp3",
|
||||
view: FeedViewType.Articles,
|
||||
},
|
||||
{
|
||||
feedId: "56584656988676096",
|
||||
title: "迷因电波",
|
||||
url: "rsshub://xiaoyuzhou/podcast/61d52b3bee197a3aac3dac44",
|
||||
view: FeedViewType.Articles,
|
||||
},
|
||||
{
|
||||
feedId: "76051724651752448",
|
||||
title: "极致音乐汇 的 bilibili 空间",
|
||||
url: "rsshub://bilibili/user/video/1691501735",
|
||||
view: FeedViewType.Videos,
|
||||
},
|
||||
{
|
||||
feedId: "66701376672681984",
|
||||
title: "AP Top News - AP News",
|
||||
url: "rsshub://apnews/api/apf-topnews",
|
||||
view: FeedViewType.Articles,
|
||||
},
|
||||
{
|
||||
feedId: "44366244616936448",
|
||||
title: "金十数据",
|
||||
url: "rsshub://jin10",
|
||||
view: FeedViewType.Articles,
|
||||
},
|
||||
{
|
||||
feedId: "52325519371718656",
|
||||
title: "Hacker News",
|
||||
url: "rsshub://hackernews",
|
||||
view: FeedViewType.Articles,
|
||||
},
|
||||
]
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Text, View } from "react-native"
|
||||
|
||||
import { Logo } from "@/src/components/ui/logo"
|
||||
|
||||
export const StepFinished = () => (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Logo width={80} height={80} />
|
||||
<Text className="text-text my-4 text-3xl font-bold">You're all set!</Text>
|
||||
<Text className="text-label mb-8 px-6 text-center text-lg">
|
||||
You have completed the guide. Enjoy your journey!
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { cn } from "@follow/utils"
|
||||
import { useCallback, useState } from "react"
|
||||
import { Text, TouchableOpacity, View } from "react-native"
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"
|
||||
|
||||
import { Search3CuteReIcon } from "@/src/icons/search_3_cute_re"
|
||||
import { Shuffle2CuteReIcon } from "@/src/icons/shuffle_2_cute_re"
|
||||
import { toast } from "@/src/lib/toast"
|
||||
import { useSubscription } from "@/src/store/subscription/hooks"
|
||||
import { subscriptionSyncService } from "@/src/store/subscription/store"
|
||||
import { accentColor } from "@/src/theme/colors"
|
||||
|
||||
import type { PresetFeedConfig } from "./preset"
|
||||
import { presetFeeds } from "./preset"
|
||||
|
||||
const subscribeFeed = async (config: PresetFeedConfig) => {
|
||||
await subscriptionSyncService.subscribe({
|
||||
feedId: config.feedId,
|
||||
title: config.title,
|
||||
url: config.url,
|
||||
view: config.view,
|
||||
category: "",
|
||||
isPrivate: false,
|
||||
})
|
||||
|
||||
toast.success(`Subscribed to ${config.title}`, {
|
||||
position: "bottom",
|
||||
})
|
||||
}
|
||||
|
||||
const unsubscribeFeed = async (feedId: string) => {
|
||||
await subscriptionSyncService.unsubscribe(feedId)
|
||||
toast.success(`Unsubscribed from feed`, {
|
||||
position: "bottom",
|
||||
})
|
||||
}
|
||||
|
||||
export const StepInterests = () => {
|
||||
const [displayFeeds, setDisplayFeeds] = useState<PresetFeedConfig[]>(presetFeeds.slice(0, 7))
|
||||
|
||||
const shuffleFeeds = useCallback(() => {
|
||||
const shuffled = [...presetFeeds].sort(() => Math.random() - 0.5).slice(0, 7)
|
||||
setDisplayFeeds(shuffled)
|
||||
}, [])
|
||||
return (
|
||||
<View className="mt-[10vh] flex-1 items-center">
|
||||
<View className="mb-10 flex items-center gap-4">
|
||||
<Search3CuteReIcon height={80} width={80} color={accentColor} />
|
||||
<Text className="text-text mt-2 text-2xl font-bold">Discover Interests</Text>
|
||||
<Text className="text-label mb-8 px-6 text-center text-lg">
|
||||
Subscribe to feeds that match your interests.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="w-full items-center gap-4">
|
||||
<View className="flex flex-row">
|
||||
<Text className="mr-2 text-base">Suggestions feed</Text>
|
||||
<TouchableOpacity
|
||||
onPress={shuffleFeeds}
|
||||
className="bg-accent/10 flex-row items-center rounded-full px-3 py-1"
|
||||
>
|
||||
<Shuffle2CuteReIcon height={16} width={16} color={accentColor} />
|
||||
<Text className="text-accent ml-1 text-sm">Shuffle</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View className="flex-row flex-wrap justify-center gap-2 px-4">
|
||||
{displayFeeds.map((feed) => (
|
||||
<FeedChip key={feed.feedId} {...feed} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const FeedChip = (feed: PresetFeedConfig) => {
|
||||
const isSubscribed = useSubscription(feed.feedId)
|
||||
|
||||
const handleSubscribe = useCallback(
|
||||
async (feed: PresetFeedConfig) => {
|
||||
if (isSubscribed) {
|
||||
await unsubscribeFeed(feed.feedId)
|
||||
return
|
||||
}
|
||||
await subscribeFeed(feed)
|
||||
},
|
||||
[isSubscribed],
|
||||
)
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
key={feed.feedId}
|
||||
entering={FadeIn.duration(300)}
|
||||
exiting={FadeOut.duration(300)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleSubscribe(feed)}
|
||||
className={cn(
|
||||
"flex rounded-full px-4 py-2",
|
||||
isSubscribed ? "bg-accent" : "bg-secondary-system-fill",
|
||||
)}
|
||||
>
|
||||
<Text className={`text-center text-sm ${isSubscribed ? "text-white" : "text-label"}`}>
|
||||
{feed.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import { router } from "expo-router"
|
||||
import type { PropsWithChildren } from "react"
|
||||
import { ActivityIndicator, Pressable, Text, TouchableOpacity, View } from "react-native"
|
||||
import { useColor } from "react-native-uikit-colors"
|
||||
|
||||
import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar"
|
||||
import { GroupedInsetListNavigationLinkIcon } from "@/src/components/ui/grouped/GroupedList"
|
||||
import { DocmentCuteReIcon } from "@/src/icons/docment_cute_re"
|
||||
import { FileImportCuteReIcon } from "@/src/icons/file_import_cute_re"
|
||||
import { ListCheck2CuteReIcon } from "@/src/icons/list_check_2_cute_re"
|
||||
import { MingcuteRightLine } from "@/src/icons/mingcute_right_line"
|
||||
import { Settings1CuteReIcon } from "@/src/icons/settings_1_cute_re"
|
||||
import { useWhoami } from "@/src/store/user/hooks"
|
||||
import { accentColor } from "@/src/theme/colors"
|
||||
|
||||
import { importOpml, setAvatar } from "../settings/utils"
|
||||
import { useReadingBehavior } from "./hooks/use-reading-behavior"
|
||||
|
||||
export const StepPreferences = () => {
|
||||
const { behavior } = useReadingBehavior()
|
||||
|
||||
return (
|
||||
<View className="mt-[10vh] flex-1 p-4">
|
||||
<View className="mb-10 flex items-center gap-4">
|
||||
<ListCheck2CuteReIcon height={80} width={80} color={accentColor} />
|
||||
<Text className="text-text mt-2 text-center text-xl font-bold">
|
||||
Personalize Your Experience
|
||||
</Text>
|
||||
<Text className="text-label text-center text-base">
|
||||
Set your preferences to make Follow work best for you. You can always change these later
|
||||
in Settings.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="mb-6 gap-4">
|
||||
<PreferenceCard
|
||||
title="Edit Profile"
|
||||
icon={
|
||||
<GroupedInsetListNavigationLinkIcon backgroundColor="#34D399">
|
||||
<Settings1CuteReIcon color="#fff" width={40} height={40} />
|
||||
</GroupedInsetListNavigationLinkIcon>
|
||||
}
|
||||
onPress={() => {
|
||||
router.push("/onboarding/edit-profile")
|
||||
}}
|
||||
>
|
||||
<Text className="text-secondary-label text-sm">
|
||||
Change your name, email, and profile picture
|
||||
</Text>
|
||||
</PreferenceCard>
|
||||
|
||||
{/* Reading Preferences Card */}
|
||||
<PreferenceCard
|
||||
title="Reading Preferences"
|
||||
icon={
|
||||
<GroupedInsetListNavigationLinkIcon backgroundColor="#F59E0B">
|
||||
<DocmentCuteReIcon color="#fff" width={40} height={40} />
|
||||
</GroupedInsetListNavigationLinkIcon>
|
||||
}
|
||||
onPress={() => {
|
||||
router.push("/onboarding/select-reading-mode")
|
||||
}}
|
||||
>
|
||||
{behavior === "radical" && (
|
||||
<Text className="text-secondary-label text-sm">
|
||||
Automatically mark entries as read when displayed
|
||||
</Text>
|
||||
)}
|
||||
{behavior === "balanced" && (
|
||||
<Text className="text-secondary-label text-sm">
|
||||
Automatically mark entries as read when scrolled out of view
|
||||
</Text>
|
||||
)}
|
||||
{behavior === "conservative" && (
|
||||
<Text className="text-secondary-label text-sm">
|
||||
Mark entries as read only when clicked
|
||||
</Text>
|
||||
)}
|
||||
</PreferenceCard>
|
||||
|
||||
{/* Import Card */}
|
||||
<PreferenceCard
|
||||
title="Import Your Content"
|
||||
icon={
|
||||
<GroupedInsetListNavigationLinkIcon backgroundColor="#CBAD6D">
|
||||
<FileImportCuteReIcon color="#fff" width={40} height={40} />
|
||||
</GroupedInsetListNavigationLinkIcon>
|
||||
}
|
||||
onPress={importOpml}
|
||||
>
|
||||
<View className="flex-row">
|
||||
<Text className="text-secondary-label flex-1">
|
||||
If you have used RSS before, you can import an OPML file
|
||||
</Text>
|
||||
</View>
|
||||
</PreferenceCard>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export const EditProfileSection = () => {
|
||||
const whoami = useWhoami()
|
||||
|
||||
if (!whoami) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<UserAvatar
|
||||
image={whoami?.image}
|
||||
name={whoami?.name}
|
||||
size={80}
|
||||
className={!whoami?.name || !whoami.image ? "bg-system-background" : ""}
|
||||
/>
|
||||
|
||||
<TouchableOpacity className="mt-2" hitSlop={10} onPress={setAvatar}>
|
||||
<Text className="text-accent text-lg">Set Avatar</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
type PreferenceCardProps = PropsWithChildren<{
|
||||
title: string
|
||||
icon?: React.ReactNode
|
||||
onPress?: () => void
|
||||
}>
|
||||
|
||||
const PreferenceCard = ({ title, children, onPress, icon }: PreferenceCardProps) => {
|
||||
const rightIconColor = useColor("tertiaryLabel")
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
className="bg-secondary-system-grouped-background flex flex-row items-center gap-2 rounded-xl p-4"
|
||||
onPress={onPress}
|
||||
>
|
||||
{icon}
|
||||
<View className="flex flex-1 flex-col gap-2">
|
||||
<Text className="text-text text-base font-medium">{title}</Text>
|
||||
{children}
|
||||
</View>
|
||||
<MingcuteRightLine height={18} width={18} color={rightIconColor} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Text, View } from "react-native"
|
||||
|
||||
import { Logo } from "@/src/components/ui/logo"
|
||||
|
||||
export const StepWelcome = () => (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Logo width={80} height={80} />
|
||||
<Text className="text-text my-4 text-3xl font-bold">Welcome to Follow!</Text>
|
||||
<Text className="text-label mb-8 px-6 text-center text-lg">
|
||||
This guide will help you get started with the app.
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
|
@ -2,7 +2,7 @@ import { withOpacity } from "@follow/utils/src/color"
|
|||
import { useMutation } from "@tanstack/react-query"
|
||||
import { router } from "expo-router"
|
||||
import type { FC } from "react"
|
||||
import { useState } from "react"
|
||||
import { useCallback, useState } from "react"
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Text,
|
||||
|
|
@ -13,6 +13,8 @@ import {
|
|||
import { KeyboardController } from "react-native-keyboard-controller"
|
||||
|
||||
import { RotateableLoading } from "@/src/components/common/RotateableLoading"
|
||||
import { ModalHeader } from "@/src/components/layouts/header/ModalHeader"
|
||||
import { SafeModalScrollView } from "@/src/components/layouts/views/SafeModalScrollView"
|
||||
import {
|
||||
NavigationBlurEffectHeader,
|
||||
SafeNavigationScrollView,
|
||||
|
|
@ -29,8 +31,6 @@ import {
|
|||
import { CheckCircleCuteReIcon } from "@/src/icons/check_circle_cute_re"
|
||||
import { CheckLineIcon } from "@/src/icons/check_line"
|
||||
import { CloseCircleFillIcon } from "@/src/icons/close_circle_fill"
|
||||
import { apiClient, apiFetch, getBizFetchErrorMessage } from "@/src/lib/api-fetch"
|
||||
import { pickImage } from "@/src/lib/native/picker"
|
||||
import { toast } from "@/src/lib/toast"
|
||||
import { useWhoami } from "@/src/store/user/hooks"
|
||||
import type { MeModel } from "@/src/store/user/store"
|
||||
|
|
@ -38,6 +38,8 @@ import { userSyncService } from "@/src/store/user/store"
|
|||
import type { UserProfileEditable } from "@/src/store/user/types"
|
||||
import { accentColor, useColor } from "@/src/theme/colors"
|
||||
|
||||
import { setAvatar } from "../utils"
|
||||
|
||||
export const EditProfileScreen = () => {
|
||||
const whoami = useWhoami()
|
||||
|
||||
|
|
@ -57,6 +59,26 @@ export const EditProfileScreen = () => {
|
|||
)
|
||||
}
|
||||
|
||||
export const EditProfileModal = () => {
|
||||
const whoami = useWhoami()
|
||||
|
||||
if (!whoami) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeModalScrollView className="bg-system-grouped-background">
|
||||
<ModalHeader headerTitle="111Edit Profile" />
|
||||
<AvatarSection whoami={whoami} />
|
||||
<ProfileForm layout="modal" whoami={whoami} />
|
||||
</SafeModalScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const AvatarSection: FC<{
|
||||
whoami: MeModel
|
||||
}> = ({ whoami }) => {
|
||||
|
|
@ -69,37 +91,7 @@ const AvatarSection: FC<{
|
|||
className={!whoami?.name || !whoami.image ? "bg-system-background" : ""}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
className="mt-2"
|
||||
hitSlop={10}
|
||||
onPress={async () => {
|
||||
const result = await pickImage({
|
||||
fileName: "avatar.jpg",
|
||||
maxSizeKB: 290,
|
||||
})
|
||||
|
||||
if (!result) return
|
||||
const { formData } = result
|
||||
const res = await apiFetch<{
|
||||
url: string
|
||||
}>(apiClient.upload.avatar.$url().toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
body: formData,
|
||||
}).catch((err) => {
|
||||
toast.error(getBizFetchErrorMessage(err))
|
||||
throw err
|
||||
})
|
||||
|
||||
const { url } = res
|
||||
|
||||
userSyncService.updateProfile({
|
||||
image: url,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity className="mt-2" hitSlop={10} onPress={setAvatar}>
|
||||
<Text className="text-accent text-lg">Set Avatar</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -108,7 +100,8 @@ const AvatarSection: FC<{
|
|||
|
||||
const ProfileForm: FC<{
|
||||
whoami: MeModel
|
||||
}> = ({ whoami }) => {
|
||||
layout?: "modal" | "screen"
|
||||
}> = ({ whoami, layout = "screen" }) => {
|
||||
const [dirtyFields, setDirtyFields] = useState<Partial<UserProfileEditable>>({})
|
||||
|
||||
const { mutateAsync: updateProfile, isPending } = useMutation({
|
||||
|
|
@ -124,27 +117,36 @@ const ProfileForm: FC<{
|
|||
})
|
||||
|
||||
const label = useColor("label")
|
||||
const headerRight = useCallback(
|
||||
() => (
|
||||
<UIBarButton
|
||||
label="Save"
|
||||
disabled={isPending || Object.keys(dirtyFields).length === 0}
|
||||
normalIcon={
|
||||
isPending ? (
|
||||
<RotateableLoading size={20} color={withOpacity(label, 0.5)} />
|
||||
) : (
|
||||
<CheckLineIcon height={20} width={20} />
|
||||
)
|
||||
}
|
||||
onPress={() => {
|
||||
updateProfile()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[dirtyFields, isPending, label, updateProfile],
|
||||
)
|
||||
|
||||
const Header =
|
||||
layout === "modal" ? (
|
||||
<ModalHeader headerRight={headerRight()} headerTitle="Edit Profile" />
|
||||
) : (
|
||||
<NavigationBlurEffectHeader headerRight={headerRight} title="Edit Profile" />
|
||||
)
|
||||
|
||||
return (
|
||||
<View className="mt-4">
|
||||
<NavigationBlurEffectHeader
|
||||
headerRight={() => (
|
||||
<UIBarButton
|
||||
label="Save"
|
||||
disabled={isPending || Object.keys(dirtyFields).length === 0}
|
||||
normalIcon={
|
||||
isPending ? (
|
||||
<RotateableLoading size={20} color={withOpacity(label, 0.5)} />
|
||||
) : (
|
||||
<CheckLineIcon height={20} width={20} />
|
||||
)
|
||||
}
|
||||
onPress={() => {
|
||||
updateProfile()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
title="Edit Profile"
|
||||
/>
|
||||
{Header}
|
||||
|
||||
<TouchableWithoutFeedback
|
||||
onPress={() => {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,38 @@ import * as FileSystem from "expo-file-system"
|
|||
import * as Sharing from "expo-sharing"
|
||||
|
||||
import { getDbPath } from "@/src/database"
|
||||
import { apiFetch, getBizFetchErrorMessage } from "@/src/lib/api-fetch"
|
||||
import { apiClient, apiFetch, getBizFetchErrorMessage } from "@/src/lib/api-fetch"
|
||||
import { pickImage } from "@/src/lib/native/picker"
|
||||
import { toast } from "@/src/lib/toast"
|
||||
import { userSyncService } from "@/src/store/user/store"
|
||||
|
||||
export const setAvatar = async () => {
|
||||
const result = await pickImage({
|
||||
fileName: "avatar.jpg",
|
||||
maxSizeKB: 290,
|
||||
})
|
||||
|
||||
if (!result) return
|
||||
const { formData } = result
|
||||
const res = await apiFetch<{
|
||||
url: string
|
||||
}>(apiClient.upload.avatar.$url().toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
body: formData,
|
||||
}).catch((err) => {
|
||||
toast.error(getBizFetchErrorMessage(err))
|
||||
throw err
|
||||
})
|
||||
|
||||
const { url } = res
|
||||
|
||||
userSyncService.updateProfile({
|
||||
image: url,
|
||||
})
|
||||
}
|
||||
|
||||
type FeedResponseList = {
|
||||
id: string
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { EditProfileModal as default } from "@/src/modules/settings/routes/EditProfile"
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { cn } from "@follow/utils"
|
||||
import type { PropsWithChildren } from "react"
|
||||
import { Pressable, Text, View } from "react-native"
|
||||
|
||||
import { ModalHeader } from "@/src/components/layouts/header/ModalHeader"
|
||||
import { SafeModalScrollView } from "@/src/components/layouts/views/SafeModalScrollView"
|
||||
import { GroupedInsetListNavigationLinkIcon } from "@/src/components/ui/grouped/GroupedList"
|
||||
import { Eye2CuteReIcon } from "@/src/icons/eye_2_cute_re"
|
||||
import { Grid2CuteReIcon } from "@/src/icons/grid_2_cute_re"
|
||||
import { PowerIcon } from "@/src/icons/power"
|
||||
import { useReadingBehavior } from "@/src/modules/onboarding/hooks/use-reading-behavior"
|
||||
|
||||
const SelectReadingModeScreen = () => {
|
||||
const { behavior, updateSettings } = useReadingBehavior()
|
||||
|
||||
return (
|
||||
<SafeModalScrollView className="bg-system-grouped-background">
|
||||
<ModalHeader headerTitle="Select Reading Mode" />
|
||||
|
||||
<View className="mt-8 flex w-full gap-4">
|
||||
<Card
|
||||
icon={
|
||||
<GroupedInsetListNavigationLinkIcon backgroundColor="#F87181">
|
||||
<PowerIcon color="#fff" width={40} height={40} />
|
||||
</GroupedInsetListNavigationLinkIcon>
|
||||
}
|
||||
isSelected={behavior === "radical"}
|
||||
onPress={() => {
|
||||
updateSettings("radical")
|
||||
}}
|
||||
>
|
||||
<Text className="text-label">
|
||||
Radical: Automatically mark entries as read when displayed
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
icon={
|
||||
<GroupedInsetListNavigationLinkIcon backgroundColor="#34D399">
|
||||
<Grid2CuteReIcon color="#fff" width={40} height={40} />
|
||||
</GroupedInsetListNavigationLinkIcon>
|
||||
}
|
||||
isSelected={behavior === "balanced"}
|
||||
onPress={() => {
|
||||
updateSettings("balanced")
|
||||
}}
|
||||
>
|
||||
<Text className="text-label">
|
||||
Balanced: Automatically mark entries as read when scrolled out of view
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
icon={
|
||||
<GroupedInsetListNavigationLinkIcon backgroundColor="#CBAD6D">
|
||||
<Eye2CuteReIcon color="#fff" width={40} height={40} />
|
||||
</GroupedInsetListNavigationLinkIcon>
|
||||
}
|
||||
isSelected={behavior === "conservative"}
|
||||
onPress={() => {
|
||||
updateSettings("conservative")
|
||||
}}
|
||||
>
|
||||
<Text className="text-label">Conservative: Mark entries as read only when clicked</Text>
|
||||
</Card>
|
||||
</View>
|
||||
</SafeModalScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const Card = ({
|
||||
children,
|
||||
onPress,
|
||||
icon,
|
||||
isSelected,
|
||||
}: PropsWithChildren<{
|
||||
icon?: React.ReactNode
|
||||
onPress?: () => void
|
||||
isSelected?: boolean
|
||||
}>) => {
|
||||
return (
|
||||
<Pressable
|
||||
className={cn(
|
||||
"bg-secondary-system-grouped-background mx-4 flex flex-row items-center gap-2 rounded-xl p-4",
|
||||
"border-2 border-transparent",
|
||||
isSelected && "border-accent border-2",
|
||||
)}
|
||||
onPress={onPress}
|
||||
>
|
||||
{icon}
|
||||
<View className="flex flex-1 flex-col gap-2">{children}</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectReadingModeScreen
|
||||
|
|
@ -81,6 +81,17 @@ function AnimatedStack() {
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="onboarding"
|
||||
options={{
|
||||
title: "Onboarding",
|
||||
presentation: "transparentModal",
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import { router } from "expo-router"
|
||||
import { useCallback, useState } from "react"
|
||||
import { Text, TouchableOpacity, View } from "react-native"
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
||||
import { SheetScreen } from "react-native-sheet-transitions"
|
||||
|
||||
import { StepFinished } from "../modules/onboarding/step-finished"
|
||||
import { StepInterests } from "../modules/onboarding/step-interests"
|
||||
import { StepPreferences } from "../modules/onboarding/step-preferences"
|
||||
import { StepWelcome } from "../modules/onboarding/step-welcome"
|
||||
|
||||
export default function Onboarding() {
|
||||
const insets = useSafeAreaInsets()
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const totalSteps = 4
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep < totalSteps) {
|
||||
setCurrentStep(currentStep + 1)
|
||||
} else {
|
||||
// Complete onboarding
|
||||
router.replace("/")
|
||||
}
|
||||
}, [currentStep])
|
||||
|
||||
return (
|
||||
<SheetScreen
|
||||
onClose={() => {
|
||||
router.back()
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{ paddingTop: insets.top }}
|
||||
className="p-safe bg-system-grouped-background flex-1 px-6"
|
||||
>
|
||||
<ProgressIndicator
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
setCurrentStep={setCurrentStep}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
{currentStep === 1 && <StepWelcome />}
|
||||
{currentStep === 2 && <StepPreferences />}
|
||||
{currentStep === 3 && <StepInterests />}
|
||||
{currentStep === 4 && <StepFinished />}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<View className="mb-6 px-6" style={{ marginBottom: insets.bottom || 24 }}>
|
||||
<TouchableOpacity
|
||||
onPress={handleNext}
|
||||
className="bg-accent w-full items-center rounded-xl py-4"
|
||||
>
|
||||
<Text className="text-lg font-bold text-white">
|
||||
{currentStep < totalSteps - 1
|
||||
? "Next"
|
||||
: currentStep === totalSteps - 1
|
||||
? "Finish Setup"
|
||||
: "Let's Go!"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</SheetScreen>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProgressIndicator({
|
||||
currentStep,
|
||||
totalSteps,
|
||||
setCurrentStep,
|
||||
}: {
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
setCurrentStep: (step: number) => void
|
||||
}) {
|
||||
return (
|
||||
<View className="mb-6 mt-4 flex flex-row justify-center gap-2">
|
||||
{Array.from({ length: totalSteps }).map((_, index) => (
|
||||
<TouchableOpacity
|
||||
key={`step-${index}-indicator`}
|
||||
onPress={() => {
|
||||
setCurrentStep(index + 1)
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={`mx-1 h-2 w-10 rounded-full ${
|
||||
currentStep >= index + 1 ? "bg-accent" : "bg-gray-300"
|
||||
}`}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5h9m-9 7h9m-9 7h9M7.945 3.72c-.941.725-1.754 1.53-2.475 2.475A4.225 4.225 0 0 0 4.056 4.78m0 7c.592.373 1.05.818 1.414 1.415a13.22 13.22 0 0 1 2.475-2.475m-3.89 8.06c.593.373 1.051.817 1.415 1.415a13.22 13.22 0 0 1 2.475-2.475"/></svg>
|
||||
|
After Width: | Height: | Size: 460 B |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linecap="round" stroke-width="2" d="M4 7v0c1.69 0 2.535 0 3.273.308a4 4 0 0 1 .837.473c.644.475 1.078 1.2 1.948 2.649l1.884 3.14c.87 1.45 1.304 2.174 1.948 2.649.259.19.54.35.836.473C15.466 17 16.31 17 18 17v0m0-10h-1.084c-.632 0-.948 0-1.241.044a4 4 0 0 0-2.5 1.415c-.188.229-.35.5-.675 1.041v0M4 17h1.085c.631 0 .947 0 1.24-.044a4 4 0 0 0 2.5-1.415c.188-.229.35-.5.675-1.041"/><path fill="#10161F" fill-rule="evenodd" d="M17.847 4.507c-.503-.263-1.102.127-1.15.748-.032.407-.064.994-.064 1.708 0 .748.035 1.357.068 1.764.048.58.592.914 1.09.654.33-.171.806-.438 1.376-.808.57-.37 1.013-.701 1.31-.936.448-.355.465-1.051.042-1.388a17.102 17.102 0 0 0-1.325-.95c-.55-.358-1.019-.62-1.347-.792M17.847 14.617c-.503-.263-1.102.127-1.15.748-.032.406-.064.994-.064 1.708 0 .747.035 1.357.068 1.764.048.58.592.914 1.09.654.33-.171.806-.438 1.376-.808.57-.37 1.013-.701 1.31-.936.448-.355.465-1.051.042-1.388a17.13 17.13 0 0 0-1.325-.95c-.55-.358-1.019-.62-1.347-.792" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
Loading…
Reference in New Issue