feat: optimize user profile modal style

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-03-24 21:05:36 +08:00
parent 70a5b8f8ed
commit 6c325d7cb8
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
12 changed files with 738 additions and 50 deletions

View File

@ -76,5 +76,8 @@
"i18n-ally.namespace": true,
"i18n-ally.pathMatcher": "{namespaces}/{locale}.json",
"lldb.library": "/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB",
"lldb.launch.expressions": "native"
"lldb.launch.expressions": "native",
"[swift]": {
"editor.defaultFormatter": "swiftlang.swift-vscode"
}
}

View File

@ -81,6 +81,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
{
ios: {
useFrameworks: "static",
deploymentTarget: "17.0",
},
},
],

View File

@ -10,7 +10,8 @@
"GaleriaAccessoryModule",
"TabBarModule",
"TabScreenModule",
"TabBarPortalModule"
"TabBarPortalModule",
"ProfileViewModule"
]
},
"android": {

View File

@ -12,8 +12,8 @@ Pod::Spec.new do |s|
s.author = package['author']
s.homepage = package['homepage']
s.platforms = {
:ios => '15.1',
:tvos => '15.1'
:ios => '17.0',
:tvos => '17.0'
}
s.swift_version = '5.4'
s.source = { git: 'https://github.com/RSSNext/follow' }

View File

@ -0,0 +1,49 @@
import Foundation
import ExpoModulesCore
struct ProfileData: Codable {
var lists: [ProfileList]
var feeds: [ProfileFeed]
var groupedFeeds: [String: [ProfileFeed]]
static var mockData: ProfileData {
let decoder = JSONDecoder()
guard let url = Bundle.main.url(forResource: "Profile", withExtension: "json"),
let data = try? Data(contentsOf: url),
let profileData = try? decoder.decode(ProfileData.self, from: data)
else {
// Return empty data if decoding fails
return ProfileData(lists: [], feeds: [], groupedFeeds: [:])
}
return profileData
}
}
struct ProfileList: Codable, Identifiable {
var id: String
var title: String
var image: String?
var description: String?
var view: FeedViewType
var customTitle: String?
}
struct ProfileFeed: Codable, Identifiable {
var id: String
var title: String
var image: String?
var description: String?
var siteUrl: String
var url: String
var view: FeedViewType
var customTitle: String?
}
enum FeedViewType: Int, Codable {
case Article = 0
case SocialMedia = 1
case Image = 2
case Video = 3
case Audio = 4
case Notification = 5
}

View File

@ -0,0 +1,37 @@
//
// UserData.swift
// SwiftUIDemo
//
// Created by Innei on 2025/3/24.
//
import Foundation
struct UserData: Codable {
var id: String
var name: String
var email: String
var emailVerified: Bool
var image: String
var createdAt: String
var updatedAt: String
var twoFactorEnabled: Bool
var isAnonymous: Bool?
var handle: String
static var mockData: UserData {
let decoder = JSONDecoder()
guard let url = Bundle.main.url(forResource: "User", withExtension: "json"),
let data = try? Data(contentsOf: url),
let profileData = try? decoder.decode(UserData.self, from: data)
else {
// Return empty data if decoding fails
return UserData(
id: "", name: "", email: "", emailVerified: false, image: "", createdAt: "", updatedAt: "",
twoFactorEnabled: false, isAnonymous: false, handle: "")
}
return profileData
}
}

View File

@ -0,0 +1,41 @@
//
// ProfileViewModule.swift
// Pods
//
// Created by Innei on 2025/3/24.
//
import ExpoModulesCore
import SwiftUI
public class ProfileViewModule: Module {
public func definition() -> ModuleDefinition {
Name("ProfileView")
Events("onPress")
View(ExpoProfileView.self)
}
}
public class ProfileViewProps: ExpoSwiftUI.ViewProps {
@Field var payload: String
let onPress = EventDispatcher()
}
struct ProfilePayload: Codable {
var profile: ProfileData
var user: UserData
public static func parse(jsonString: String) -> ProfilePayload? {
let data = jsonString.data(using: .utf8)!
let decoder = JSONDecoder()
return try? decoder.decode(ProfilePayload.self, from: data)
}
}
struct ExpoProfileView: ExpoSwiftUI.View {
@EnvironmentObject var props: ProfileViewProps
var body: some View {
if let payload = ProfilePayload.parse(jsonString: props.payload) {
ProfileView(profile: .constant(payload.profile), user: .constant(payload.user), onPress: props.onPress)
}
}
}

View File

@ -0,0 +1,80 @@
//
// FallbackIconView.swift
// FollowNative
//
// Created by Innei on 2025/3/24.
//
import SwiftUI
struct FallbackIcon: View {
var title: String
var size: CGFloat
var gray: Bool = false
var body: some View {
ZStack {
// Generate gradient colors based on the title
LinearGradient(
gradient: getGradientForTitle(title: title, gray: gray),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size * 0.2))
// Display first character or first two characters based on if it's CJK
Text(getDisplayText(from: title))
.font(.system(size: size * 0.5))
.foregroundColor(.white)
}
}
private func getDisplayText(from title: String) -> String {
guard !title.isEmpty else { return "" }
// Check if the first character is CJK (Chinese, Japanese, Korean)
let firstChar = title.first!
let isCJK = isCJKCharacter(firstChar)
if isCJK {
return String(firstChar)
} else {
return title.prefix(2).uppercased()
}
}
private func isCJKCharacter(_ character: Character) -> Bool {
// Unicode ranges for CJK characters
let cjkRanges: [ClosedRange<UInt32>] = [
0x4E00...0x9FFF, // CJK Unified Ideographs
0x3040...0x309F, // Hiragana
0x30A0...0x30FF, // Katakana
0xAC00...0xD7AF, // Hangul Syllables
]
let unicodeScalar = character.unicodeScalars.first!.value
return cjkRanges.contains { $0.contains(unicodeScalar) }
}
private func getGradientForTitle(title: String, gray: Bool) -> Gradient {
if gray {
return Gradient(colors: [Color.gray, Color.gray.opacity(0.7)])
}
// Simple hash function to generate consistent colors based on title
var hash = 0
for char in title {
hash = ((hash << 5) &- hash) &+ Int(char.asciiValue ?? 0)
}
// Generate hue values between 0 and 1
let hue = abs(Double(hash % 360) / 360.0)
return Gradient(colors: [
Color(hue: hue, saturation: 0.7, brightness: 0.8),
Color(hue: hue, saturation: 0.5, brightness: 0.6),
])
}
}

View File

@ -0,0 +1,167 @@
//
// FeedIconView.swift
//
// Created by Innei on 2025/3/24.
//
import SwiftUI
struct FeedIconView: View {
var feed: FeedIconFeedData?
var fallbackUrl: String?
var size: CGFloat = 20
var fallback: Bool = true
var siteUrl: String?
var body: some View {
if let iconSource = getFeedIconSource() {
AsyncImage(url: URL(string: iconSource)) { phase in
switch phase {
case .empty:
RoundedRectangle(cornerRadius: CGFloat(4), style: .continuous)
.fill(Color.gray.opacity(0.5))
.frame(width: size, height: size)
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 4))
case .failure:
let fallbackTitle = feed?.title ?? extractDomainFromUrl(siteUrl ?? fallbackUrl ?? "")
FallbackIcon(title: fallbackTitle, size: size)
@unknown default:
RoundedRectangle(cornerRadius: CGFloat(4), style: .continuous)
.fill(Color.gray.opacity(0.5))
.frame(width: size, height: size)
}
}
.frame(width: size, height: size)
} else {
let fallbackTitle = feed?.title ?? extractDomainFromUrl(siteUrl ?? fallbackUrl ?? "")
FallbackIcon(title: fallbackTitle, size: size)
}
}
private func getFeedIconSource() -> String? {
switch true {
case feed == nil && siteUrl != nil:
return getUrlIcon(url: siteUrl!, fallback: fallback).src
case feed != nil && feed?.image != nil && !(feed?.image?.isEmpty ?? true):
return feed?.image
case feed != nil && (feed?.image == nil || feed?.image?.isEmpty ?? true)
&& feed?.siteUrl != nil:
return getUrlIcon(url: feed!.siteUrl!, fallback: fallback).src
default:
return nil
}
}
private func getUrlIcon(url: String, fallback: Bool) -> (src: String, fallbackUrl: String) {
var src: String
var fallbackUrl = ""
if let urlObj = URL(string: url), let host = urlObj.host {
let pureDomain = extractDomainFromUrl(host)
fallbackUrl =
"https://avatar.vercel.sh/\(pureDomain).svg?text=\(pureDomain.prefix(2).uppercased())"
src = "https://unavatar.webp.se/\(host)?fallback=\(fallback)"
} else {
let pureDomain = extractDomainFromUrl(url)
src = "https://avatar.vercel.sh/\(pureDomain).svg?text=\(pureDomain.prefix(2).uppercased())"
}
return (src, fallbackUrl)
}
private func extractDomainFromUrl(_ url: String) -> String {
// Simple domain extraction - in a real app you'd want to use a more robust solution
let components = url.components(separatedBy: ".")
if components.count >= 2 {
return components[components.count - 2]
}
return url
}
}
// Feed data model
struct FeedIconFeedData: Identifiable {
var id: String
var title: String
var url: String
var image: String?
var siteUrl: String?
static func transform(_ feed: ProfileFeed) -> FeedIconFeedData {
FeedIconFeedData(
id: feed.id,
title: feed.title,
url: feed.url,
image: feed.image,
siteUrl: feed.siteUrl
)
}
#if DEBUG
static var mockData: FeedIconFeedData {
FeedIconFeedData(
id: "1",
title: "Example Feed",
url: "https://example.com/feed",
image: nil,
siteUrl: "https://example.com"
)
}
#endif
}
#Preview {
VStack(spacing: 20) {
// Feed with image
FeedIconView(
feed: FeedIconFeedData(
id: "1",
title: "Example Feed",
url: "https://example.com/feed",
image: "https://picsum.photos/200",
siteUrl: "https://example.com"
),
size: 40
)
// Feed without image, using site URL
FeedIconView(
feed: FeedIconFeedData(
id: "2",
title: "No Image Feed",
url: "https://noimage.com/feed",
image: nil,
siteUrl: "https://noimage.com"
),
size: 40
)
// Just a site URL
FeedIconView(
size: 40, siteUrl: "https://github.com"
)
// Fallback
FeedIconView(
feed: FeedIconFeedData(
id: "3",
title: "Fallback Example",
url: "https://invalid-url",
image: nil,
siteUrl: nil
),
size: 40
)
}
.padding()
}

View File

@ -0,0 +1,182 @@
//
// ProfileView.swift
//
// Created by Innei on 2025/3/24.
//
import ExpoModulesCore
import SwiftUI
struct ProfileView: View {
@Binding var profile: ProfileData
@Binding var user: UserData
let onPress: EventDispatcher
var hasAnyFeeds: Bool {
!profile.feeds.isEmpty || !profile.groupedFeeds.isEmpty
}
var body: some View {
List {
VStack {
VStack {
ProfileAvatar(image: user.image, name: user.name)
Text(user.name)
.font(.title)
.fontWeight(.bold)
.lineLimit(1)
if !user.handle.isEmpty {
Text("@\(user.handle)")
.foregroundColor(.gray)
}
}
.frame(maxWidth: .infinity)
.padding(.top, 30)
.padding(.bottom, 20)
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
}.listRowInsets(.none)
if !profile.lists.isEmpty {
Section(header: SectionHeaderText(text: "Lists")) {
ForEach($profile.lists, id: \.id) { $list in
Button {
onPress(["type": "list", "id": list.id])
} label: {
HStack {
ProfileListImage(
image: list.image, title: list.title,
customTitle: list.customTitle)
ProfileItemTitle(title: list.title, customTitle: list.customTitle)
}
}.foregroundStyle(.foreground)
}
}
}
if hasAnyFeeds {
Section(header: SectionHeaderText(text: "Feeds")) {
}
}
ForEach(profile.groupedFeeds.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
Section(
header:
Text(key).textCase(.none)
) {
ForEach(value, id: \.id) { feed in
Button {
onPress(["type": "feed", "id": feed.id])
} label: {
HStack {
FeedIconView(
feed: FeedIconFeedData.transform(feed), size: 24,
fallback: true,
siteUrl: feed.siteUrl
)
ProfileItemTitle(title: feed.title, customTitle: feed.customTitle)
}
}.foregroundStyle(.foreground)
}
}
}
if !profile.feeds.isEmpty {
Section(header: Text("Uncategorized Feeds").textCase(.none)) {
ForEach($profile.feeds, id: \.id) { $feed in
Button {
onPress(["type": "feed", "id": feed.id])
} label: {
HStack {
FeedIconView(
feed: FeedIconFeedData.transform(feed), size: 24,
fallback: true,
siteUrl: feed.siteUrl
)
ProfileItemTitle(title: feed.title, customTitle: feed.customTitle)
}
}.foregroundStyle(.foreground)
}
}
}
}.listStyle(.insetGrouped)
.listSectionSpacing(0)
}
}
private struct ProfileListImage: View {
var image: String?
var title: String
var customTitle: String?
var body: some View {
if let image = image, !image.isEmpty {
Image(systemName: image)
.frame(width: 24, height: 24)
} else {
// Fallback icon with gradient background based on title
FallbackIcon(title: customTitle ?? title, size: 24)
}
}
}
private struct SectionHeaderText: View {
let text: String
var body: some View {
Text(text).font(.headline).foregroundStyle(.black).textCase(.none).padding(.top, 20)
}
}
private struct ProfileItemTitle: View {
let title: String
let customTitle: String?
var body: some View {
if let customTitle = customTitle, !customTitle.isEmpty {
Text(customTitle).lineLimit(1)
} else {
Text(title).lineLimit(1)
}
}
}
private struct ProfileAvatar: View {
let image: String?
let name: String
var body: some View {
AsyncImage(url: URL(string: image ?? "")) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
FallbackIcon(title: name, size: 80)
@unknown default:
FallbackIcon(title: name, size: 80)
}
}
.scaledToFill()
.frame(width: 80, height: 80)
.clipShape(Circle())
}
}

View File

@ -0,0 +1,116 @@
import type { FeedViewType } from "@follow/constants"
import { requireNativeView } from "expo"
import { useEffect, useMemo } from "react"
import { ActivityIndicator, View } from "react-native"
import type { apiClient } from "@/src/lib/api-fetch"
import type { NavigationControllerView } from "@/src/lib/navigation/types"
import { toast } from "@/src/lib/toast"
import { useShareSubscription } from "@/src/modules/settings/hooks/useShareSubscription"
import type { FeedModel } from "@/src/store/feed/types"
import type { ListModel } from "@/src/store/list/store"
import { useWhoami } from "@/src/store/user/hooks"
const ProfileView = requireNativeView("ProfileView")
type Subscription = Awaited<ReturnType<typeof apiClient.subscriptions.$get>>["data"][number]
export const ProfileScreen: NavigationControllerView<{
userId: string
}> = ({ userId }) => {
const whoami = useWhoami()
if (!whoami) {
return null
}
return <ProfileScreenImpl userId={userId || whoami?.id} />
}
function ProfileScreenImpl(props: { userId: string }) {
const {
data: subscriptions,
isLoading,
isError,
} = useShareSubscription({
userId: props.userId,
})
useEffect(() => {
if (isError) {
toast.error("Failed to fetch subscriptions")
}
}, [isError])
return (
<View className="bg-system-grouped-background flex-1">
{isLoading && <ActivityIndicator className="mt-24" size={28} />}
{!isLoading && subscriptions && <SubscriptionList subscriptions={subscriptions.data} />}
</View>
)
}
type PickedListModel = Pick<ListModel, "id" | "title" | "image" | "description" | "view"> & {
customTitle?: string | null
}
type PickedFeedModel = Pick<
FeedModel,
"id" | "title" | "description" | "siteUrl" | "url" | "image"
> & {
customTitle?: string | null
view: FeedViewType
}
const SubscriptionList = ({ subscriptions }: { subscriptions: Subscription[] }) => {
const { lists, feeds, groupedFeeds } = useMemo(() => {
const lists = [] as PickedListModel[]
const feeds = [] as PickedFeedModel[]
const groupedFeeds = {} as Record<string, PickedFeedModel[]>
for (const subscription of subscriptions) {
if ("listId" in subscription) {
lists.push({
id: subscription.listId,
title: subscription.lists.title!,
image: subscription.lists.image!,
description: subscription.lists.description!,
view: subscription.lists.view,
customTitle: subscription.title,
})
continue
}
if ("feedId" in subscription && "feeds" in subscription) {
const feed = {
id: subscription.feedId,
title: subscription.feeds.title!,
image: subscription.feeds.image!,
description: subscription.feeds.description!,
siteUrl: subscription.feeds.siteUrl!,
url: subscription.feeds.url!,
view: subscription.view as FeedViewType,
customTitle: subscription.title,
}
if (subscription.category) {
groupedFeeds[subscription.category] = [
...(groupedFeeds[subscription.category] || []),
feed,
]
} else {
feeds.push(feed)
}
}
}
return { lists, feeds, groupedFeeds }
}, [subscriptions])
const user = useWhoami()
return (
<ProfileView
style={{ flex: 1 }}
payload={JSON.stringify({
user,
profile: { lists, feeds, groupedFeeds },
})}
/>
)
}

View File

@ -1,12 +1,10 @@
import type { FeedViewType } from "@follow/constants"
import { cn } from "@follow/utils"
import { Fragment, useCallback, useEffect, useMemo } from "react"
import {
ActivityIndicator,
FlatList,
Image,
Share,
StyleSheet,
Text,
TouchableOpacity,
View,
@ -26,6 +24,15 @@ import {
UINavigationHeaderActionButton,
} from "@/src/components/layouts/header/NavigationHeader"
import { getDefaultHeaderHeight } from "@/src/components/layouts/utils"
import {
GROUPED_ICON_TEXT_GAP,
GROUPED_LIST_ITEM_PADDING,
GROUPED_LIST_MARGIN,
} from "@/src/components/ui/grouped/constants"
import {
GroupedInsetListCard,
GroupedInsetListSectionHeader,
} from "@/src/components/ui/grouped/GroupedList"
import { FallbackIcon } from "@/src/components/ui/icon/fallback-icon"
import type { FeedIconRequiredFeed } from "@/src/components/ui/icon/feed-icon"
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
@ -196,52 +203,52 @@ const SubscriptionList = ({ subscriptions }: { subscriptions: Subscription[] })
<Fragment>
<SectionHeader title="Lists" />
<FlatList
scrollEnabled={false}
data={lists}
renderItem={renderListItems}
ItemSeparatorComponent={ItemSeparator}
/>
<GroupedInsetListCard>
<FlatList
scrollEnabled={false}
data={lists}
renderItem={renderListItems}
ItemSeparatorComponent={ItemSeparator}
/>
</GroupedInsetListCard>
</Fragment>
)}
{hasFeeds && (
<View className="mt-4">
<SectionHeader title="Feeds" />
{Object.entries(groupedFeeds).map(([category, feeds], index) => (
{Object.entries(groupedFeeds).map(([category, feeds]) => (
<Fragment key={category}>
<Text
className={cn(
"text-secondary-label mb-2 ml-3 text-sm font-medium",
index !== 0 ? "mt-6" : "",
)}
>
{category}
</Text>
<FlatList
scrollEnabled={false}
data={feeds}
renderItem={renderFeedItems}
ItemSeparatorComponent={ItemSeparator}
/>
<GroupedInsetListSectionHeader label={category} marginSize="small" />
<GroupedInsetListCard>
<FlatList
scrollEnabled={false}
data={feeds}
renderItem={renderFeedItems}
ItemSeparatorComponent={ItemSeparator}
/>
</GroupedInsetListCard>
</Fragment>
))}
<Text className="text-secondary-label mb-2 ml-3 mt-6 text-sm font-medium">
Uncategorized Feeds
</Text>
<FlatList
scrollEnabled={false}
data={feeds}
renderItem={renderFeedItems}
ItemSeparatorComponent={ItemSeparator}
/>
<GroupedInsetListSectionHeader label="Uncategorized Feeds" marginSize="small" />
<GroupedInsetListCard>
<FlatList
scrollEnabled={false}
data={feeds}
renderItem={renderFeedItems}
ItemSeparatorComponent={ItemSeparator}
/>
</GroupedInsetListCard>
</View>
)}
</View>
)
}
const renderListItems = ({ item }: { item: PickedListModel }) => (
<View className="bg-secondary-system-grouped-background h-12 flex-row items-center px-3">
<View
className="bg-secondary-system-grouped-background h-12 flex-row items-center"
style={{ paddingHorizontal: GROUPED_LIST_ITEM_PADDING }}
>
<View className="overflow-hidden rounded">
{!!item.image && (
<Image source={{ uri: item.image, width: 24, height: 24 }} resizeMode="cover" />
@ -249,12 +256,17 @@ const renderListItems = ({ item }: { item: PickedListModel }) => (
{!item.image && <FallbackIcon title={item.title} size={24} />}
</View>
<Text className="text-text ml-2">{item.title}</Text>
<Text className="text-text" style={{ marginLeft: GROUPED_ICON_TEXT_GAP }}>
{item.title}
</Text>
</View>
)
const renderFeedItems = ({ item }: { item: PickedFeedModel }) => (
<View className="bg-secondary-system-grouped-background h-12 flex-row items-center px-3">
<View
className="bg-secondary-system-grouped-background h-12 flex-row items-center"
style={{ paddingHorizontal: GROUPED_LIST_ITEM_PADDING }}
>
<View className="overflow-hidden rounded">
<FeedIcon
feed={
@ -270,20 +282,19 @@ const renderFeedItems = ({ item }: { item: PickedFeedModel }) => (
size={24}
/>
</View>
<Text className="text-text ml-2">{item.title}</Text>
<Text className="text-text" style={{ marginLeft: GROUPED_ICON_TEXT_GAP }}>
{item.title}
</Text>
</View>
)
const SectionHeader = ({ title }: { title: string }) => (
<View className="my-5 flex-row items-center justify-center gap-4">
<View
className="bg-secondary-label w-12 rounded-full"
style={{ height: StyleSheet.hairlineWidth }}
/>
<Text className="text-secondary-label text-sm font-medium">{title}</Text>
<View
className="bg-secondary-label w-12 rounded-full"
style={{ height: StyleSheet.hairlineWidth }}
/>
<View className="mb-2 mt-5" style={{ marginHorizontal: GROUPED_LIST_MARGIN }}>
<Text
className="text-label text-xl font-medium"
style={{ marginLeft: GROUPED_LIST_ITEM_PADDING }}
>
{title}
</Text>
</View>
)