From 6c325d7cb8bb2be3877b5dd519a8bf1aa8c47f64 Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 24 Mar 2025 21:05:36 +0800 Subject: [PATCH] feat: optimize user profile modal style Signed-off-by: Innei --- .vscode/settings.json | 5 +- apps/mobile/app.config.ts | 1 + apps/mobile/native/expo-module.config.json | 3 +- apps/mobile/native/ios/FollowNative.podspec | 4 +- .../native/ios/Models/ProfileData.swift | 49 +++++ apps/mobile/native/ios/Models/UserData.swift | 37 ++++ .../ProfileView/ProfileViewModule.swift | 41 ++++ .../native/ios/Views/FallbackIconView.swift | 80 ++++++++ .../native/ios/Views/FeedIconView.swift | 167 ++++++++++++++++ .../mobile/native/ios/Views/ProfileView.swift | 182 ++++++++++++++++++ .../src/screens/(modal)/profile.ios.tsx | 116 +++++++++++ apps/mobile/src/screens/(modal)/profile.tsx | 103 +++++----- 12 files changed, 738 insertions(+), 50 deletions(-) create mode 100644 apps/mobile/native/ios/Models/ProfileData.swift create mode 100644 apps/mobile/native/ios/Models/UserData.swift create mode 100644 apps/mobile/native/ios/Modules/ProfileView/ProfileViewModule.swift create mode 100644 apps/mobile/native/ios/Views/FallbackIconView.swift create mode 100644 apps/mobile/native/ios/Views/FeedIconView.swift create mode 100644 apps/mobile/native/ios/Views/ProfileView.swift create mode 100644 apps/mobile/src/screens/(modal)/profile.ios.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index cd8bc0209..1f1be9ddb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" + } } diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index fcf6ca2e0..58df4451f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -81,6 +81,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ { ios: { useFrameworks: "static", + deploymentTarget: "17.0", }, }, ], diff --git a/apps/mobile/native/expo-module.config.json b/apps/mobile/native/expo-module.config.json index 1e2f2b786..ce59337ef 100644 --- a/apps/mobile/native/expo-module.config.json +++ b/apps/mobile/native/expo-module.config.json @@ -10,7 +10,8 @@ "GaleriaAccessoryModule", "TabBarModule", "TabScreenModule", - "TabBarPortalModule" + "TabBarPortalModule", + "ProfileViewModule" ] }, "android": { diff --git a/apps/mobile/native/ios/FollowNative.podspec b/apps/mobile/native/ios/FollowNative.podspec index e54627831..e478ab026 100644 --- a/apps/mobile/native/ios/FollowNative.podspec +++ b/apps/mobile/native/ios/FollowNative.podspec @@ -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' } diff --git a/apps/mobile/native/ios/Models/ProfileData.swift b/apps/mobile/native/ios/Models/ProfileData.swift new file mode 100644 index 000000000..abdb774f7 --- /dev/null +++ b/apps/mobile/native/ios/Models/ProfileData.swift @@ -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 +} diff --git a/apps/mobile/native/ios/Models/UserData.swift b/apps/mobile/native/ios/Models/UserData.swift new file mode 100644 index 000000000..aecb84e6d --- /dev/null +++ b/apps/mobile/native/ios/Models/UserData.swift @@ -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 + } + +} diff --git a/apps/mobile/native/ios/Modules/ProfileView/ProfileViewModule.swift b/apps/mobile/native/ios/Modules/ProfileView/ProfileViewModule.swift new file mode 100644 index 000000000..f44ff685b --- /dev/null +++ b/apps/mobile/native/ios/Modules/ProfileView/ProfileViewModule.swift @@ -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) + } + } +} diff --git a/apps/mobile/native/ios/Views/FallbackIconView.swift b/apps/mobile/native/ios/Views/FallbackIconView.swift new file mode 100644 index 000000000..f0d9259f1 --- /dev/null +++ b/apps/mobile/native/ios/Views/FallbackIconView.swift @@ -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] = [ + 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), + ]) + } +} + diff --git a/apps/mobile/native/ios/Views/FeedIconView.swift b/apps/mobile/native/ios/Views/FeedIconView.swift new file mode 100644 index 000000000..8f5f6e56a --- /dev/null +++ b/apps/mobile/native/ios/Views/FeedIconView.swift @@ -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() +} diff --git a/apps/mobile/native/ios/Views/ProfileView.swift b/apps/mobile/native/ios/Views/ProfileView.swift new file mode 100644 index 000000000..9426c89d9 --- /dev/null +++ b/apps/mobile/native/ios/Views/ProfileView.swift @@ -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()) + + } +} diff --git a/apps/mobile/src/screens/(modal)/profile.ios.tsx b/apps/mobile/src/screens/(modal)/profile.ios.tsx new file mode 100644 index 000000000..47b5c4df0 --- /dev/null +++ b/apps/mobile/src/screens/(modal)/profile.ios.tsx @@ -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>["data"][number] + +export const ProfileScreen: NavigationControllerView<{ + userId: string +}> = ({ userId }) => { + const whoami = useWhoami() + + if (!whoami) { + return null + } + return +} + +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 ( + + {isLoading && } + {!isLoading && subscriptions && } + + ) +} + +type PickedListModel = Pick & { + 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 + + 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 ( + + ) +} diff --git a/apps/mobile/src/screens/(modal)/profile.tsx b/apps/mobile/src/screens/(modal)/profile.tsx index 68ad150fd..670da0b50 100644 --- a/apps/mobile/src/screens/(modal)/profile.tsx +++ b/apps/mobile/src/screens/(modal)/profile.tsx @@ -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[] }) - + + + )} {hasFeeds && ( - {Object.entries(groupedFeeds).map(([category, feeds], index) => ( + {Object.entries(groupedFeeds).map(([category, feeds]) => ( - - {category} - - + + + + ))} - - Uncategorized Feeds - - + + + + )} ) } const renderListItems = ({ item }: { item: PickedListModel }) => ( - + {!!item.image && ( @@ -249,12 +256,17 @@ const renderListItems = ({ item }: { item: PickedListModel }) => ( {!item.image && } - {item.title} + + {item.title} + ) const renderFeedItems = ({ item }: { item: PickedFeedModel }) => ( - + ( size={24} /> - {item.title} + + {item.title} + ) const SectionHeader = ({ title }: { title: string }) => ( - - - {title} - + + + {title} + )