From 951b60a8462860e4501aa2e0403ceae1c1a8e546 Mon Sep 17 00:00:00 2001 From: Lumaa Date: Thu, 4 Jan 2024 22:19:35 +0100 Subject: [PATCH] Attachments, settings, begin of quotes... --- Threaded.xcodeproj/project.pbxproj | 24 ++ Threaded/AppDelegate.swift | 49 +++- Threaded/Components/CompactPostView.swift | 44 +-- Threaded/Components/ListStyle.swift | 19 +- Threaded/Components/OnlineImage.swift | 42 ++- Threaded/Components/PostAttachment.swift | 151 +++++++++- Threaded/Components/QuotePostView.swift | 1 - Threaded/Data/Navigator.swift | 3 + Threaded/Data/UserPreferences.swift | 73 +++++ Threaded/Localizable.xcstrings | 127 ++++++++- Threaded/Views/ContentView.swift | 3 + Threaded/Views/PostDetailsView.swift | 279 ++++++++++++++++++- Threaded/Views/PostingView.swift | 4 +- Threaded/Views/Settings/AboutView.swift | 34 ++- Threaded/Views/Settings/AppearenceView.swift | 97 ++++++- Threaded/Views/Settings/SettingsView.swift | 61 ++-- 16 files changed, 929 insertions(+), 82 deletions(-) diff --git a/Threaded.xcodeproj/project.pbxproj b/Threaded.xcodeproj/project.pbxproj index 42209c1..ab62b87 100644 --- a/Threaded.xcodeproj/project.pbxproj +++ b/Threaded.xcodeproj/project.pbxproj @@ -25,11 +25,17 @@ B9842C142B2F310C00D9F3C1 /* FetchTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9842C132B2F310C00D9F3C1 /* FetchTimeline.swift */; }; B9842C162B2F363600D9F3C1 /* TimelineFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9842C152B2F363600D9F3C1 /* TimelineFilter.swift */; }; B9842C182B2F36F500D9F3C1 /* AccountsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9842C172B2F36F500D9F3C1 /* AccountsList.swift */; }; + B98BC7472B46CE6300595441 /* PostDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B98BC7462B46CE6300595441 /* PostDetailsView.swift */; }; + B98BC7492B46CEDA00595441 /* AppearenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B98BC7482B46CEDA00595441 /* AppearenceView.swift */; }; + B98BC74B2B46CF0400595441 /* ListStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B98BC74A2B46CF0400595441 /* ListStyle.swift */; }; + B98BC74D2B46CFCE00595441 /* UserPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = B98BC74C2B46CFCE00595441 /* UserPreferences.swift */; }; B9B63B212B442D1500BBC82D /* DynamicTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B63B202B442D1500BBC82D /* DynamicTextEditor.swift */; }; B9B63B232B447B8000BBC82D /* PostCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B63B222B447B8000BBC82D /* PostCardView.swift */; }; B9B63B252B44997400BBC82D /* QuotePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B63B242B44997400BBC82D /* QuotePostView.swift */; }; B9B63B272B449CDC00BBC82D /* SearchResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B63B262B449CDC00BBC82D /* SearchResults.swift */; }; B9CC45B82B40A2D6001E4FA5 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9CC45B72B40A2D6001E4FA5 /* AboutView.swift */; }; + B9EBE8562B47256900FB594D /* PostAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EBE8552B47256900FB594D /* PostAttachment.swift */; }; + B9EBE8582B474FD600FB594D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EBE8572B474FD600FB594D /* AppDelegate.swift */; }; B9FB945B2B2DEECE00D81C07 /* ThreadedApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB945A2B2DEECE00D81C07 /* ThreadedApp.swift */; }; B9FB945D2B2DEECE00D81C07 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB945C2B2DEECE00D81C07 /* ContentView.swift */; }; B9FB94612B2DEECF00D81C07 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B9FB94602B2DEECF00D81C07 /* Assets.xcassets */; }; @@ -99,12 +105,18 @@ B9842C132B2F310C00D9F3C1 /* FetchTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchTimeline.swift; sourceTree = ""; }; B9842C152B2F363600D9F3C1 /* TimelineFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineFilter.swift; sourceTree = ""; }; B9842C172B2F36F500D9F3C1 /* AccountsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsList.swift; sourceTree = ""; }; + B98BC7462B46CE6300595441 /* PostDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostDetailsView.swift; sourceTree = ""; }; + B98BC7482B46CEDA00595441 /* AppearenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearenceView.swift; sourceTree = ""; }; + B98BC74A2B46CF0400595441 /* ListStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListStyle.swift; sourceTree = ""; }; + B98BC74C2B46CFCE00595441 /* UserPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferences.swift; sourceTree = ""; }; B9B63B202B442D1500BBC82D /* DynamicTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicTextEditor.swift; sourceTree = ""; }; B9B63B222B447B8000BBC82D /* PostCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostCardView.swift; sourceTree = ""; }; B9B63B242B44997400BBC82D /* QuotePostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotePostView.swift; sourceTree = ""; }; B9B63B262B449CDC00BBC82D /* SearchResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResults.swift; sourceTree = ""; }; B9CC45B72B40A2D6001E4FA5 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; B9CC45B92B40AA1E001E4FA5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + B9EBE8552B47256900FB594D /* PostAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostAttachment.swift; sourceTree = ""; }; + B9EBE8572B474FD600FB594D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; B9FB94572B2DEECE00D81C07 /* Threaded.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Threaded.app; sourceTree = BUILT_PRODUCTS_DIR; }; B9FB945A2B2DEECE00D81C07 /* ThreadedApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadedApp.swift; sourceTree = ""; }; B9FB945C2B2DEECE00D81C07 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -190,6 +202,7 @@ children = ( B9FB94A02B2EF23100D81C07 /* Info.plist */, B9FB945A2B2DEECE00D81C07 /* ThreadedApp.swift */, + B9EBE8572B474FD600FB594D /* AppDelegate.swift */, B9FB946E2B2DF3BB00D81C07 /* Components */, B9FB946D2B2DF3B800D81C07 /* Views */, B9FB946C2B2DF3A600D81C07 /* Data */, @@ -214,6 +227,7 @@ B9FB94BD2B2F038D00D81C07 /* Accounts */, B9FB946F2B2DF3CD00D81C07 /* Navigator.swift */, B9FB94A12B2EF24A00D81C07 /* AppInfo.swift */, + B98BC74C2B46CFCE00595441 /* UserPreferences.swift */, B9FB94BB2B2F035500D81C07 /* Tag.swift */, B9FB94802B2E1FEF00D81C07 /* HTMLString.swift */, B9FB94872B2E223E00D81C07 /* Emoji.swift */, @@ -240,6 +254,7 @@ B9842C112B2F2A5800D9F3C1 /* TimelineView.swift */, B97BCE252B3DE5A10044756D /* AccountView.swift */, B93B677B2B433A6E000892E9 /* PostingView.swift */, + B98BC7462B46CE6300595441 /* PostDetailsView.swift */, ); path = Views; sourceTree = ""; @@ -256,6 +271,8 @@ B9B63B202B442D1500BBC82D /* DynamicTextEditor.swift */, B9B63B222B447B8000BBC82D /* PostCardView.swift */, B9B63B242B44997400BBC82D /* QuotePostView.swift */, + B98BC74A2B46CF0400595441 /* ListStyle.swift */, + B9EBE8552B47256900FB594D /* PostAttachment.swift */, ); path = Components; sourceTree = ""; @@ -266,6 +283,7 @@ B9FB94912B2E35D000D81C07 /* SettingsView.swift */, B9FB94962B2EDABF00D81C07 /* PrivacyView.swift */, B9CC45B72B40A2D6001E4FA5 /* AboutView.swift */, + B98BC7482B46CEDA00595441 /* AppearenceView.swift */, ); path = Settings; sourceTree = ""; @@ -417,6 +435,7 @@ buildActionMask = 2147483647; files = ( B9FB94922B2E35D000D81C07 /* SettingsView.swift in Sources */, + B98BC7472B46CE6300595441 /* PostDetailsView.swift in Sources */, B9CC45B82B40A2D6001E4FA5 /* AboutView.swift in Sources */, B9FB94882B2E223E00D81C07 /* Emoji.swift in Sources */, B93B67782B42E8F0000892E9 /* TextEmoji.swift in Sources */, @@ -424,6 +443,7 @@ B9FB947D2B2E19E300D81C07 /* AccountManager.swift in Sources */, B9FB945D2B2DEECE00D81C07 /* ContentView.swift in Sources */, B9842C0E2B2F21B700D9F3C1 /* CompactPostView.swift in Sources */, + B98BC7492B46CEDA00595441 /* AppearenceView.swift in Sources */, B9FB94992B2EEB9400D81C07 /* AddInstanceView.swift in Sources */, B9FB94972B2EDABF00D81C07 /* PrivacyView.swift in Sources */, B9842C142B2F310C00D9F3C1 /* FetchTimeline.swift in Sources */, @@ -437,12 +457,15 @@ B9FB945B2B2DEECE00D81C07 /* ThreadedApp.swift in Sources */, B9FB94862B2E211200D81C07 /* Account+Elms.swift in Sources */, B9FB94BC2B2F035500D81C07 /* Tag.swift in Sources */, + B98BC74B2B46CF0400595441 /* ListStyle.swift in Sources */, B9FB94812B2E1FEF00D81C07 /* HTMLString.swift in Sources */, B9FB947F2B2E1D5F00D81C07 /* Account.swift in Sources */, B9842C122B2F2A5800D9F3C1 /* TimelineView.swift in Sources */, B9FB948C2B2E232300D81C07 /* OnlineImage.swift in Sources */, B9FB94742B2DF6A100D81C07 /* ButtonStyles.swift in Sources */, B9FB94702B2DF3CD00D81C07 /* Navigator.swift in Sources */, + B9EBE8562B47256900FB594D /* PostAttachment.swift in Sources */, + B9EBE8582B474FD600FB594D /* AppDelegate.swift in Sources */, B93B677C2B433A6E000892E9 /* PostingView.swift in Sources */, B97BCE262B3DE5A10044756D /* AccountView.swift in Sources */, B9B63B272B449CDC00BBC82D /* SearchResults.swift in Sources */, @@ -452,6 +475,7 @@ B9FB949F2B2EF0F200D81C07 /* MastodonRequest.swift in Sources */, B9842C182B2F36F500D9F3C1 /* AccountsList.swift in Sources */, B9FB948E2B2E28E800D81C07 /* ShareableImage.swift in Sources */, + B98BC74D2B46CFCE00595441 /* UserPreferences.swift in Sources */, B9FB94A22B2EF24A00D81C07 /* AppInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Threaded/AppDelegate.swift b/Threaded/AppDelegate.swift index 42fd563..ecbde15 100644 --- a/Threaded/AppDelegate.swift +++ b/Threaded/AppDelegate.swift @@ -1,13 +1,48 @@ //Made by Lumaa import SwiftUI +import UIKit -struct AppDelegate: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) +@Observable +public class AppDelegate: NSObject, UIWindowSceneDelegate, Sendable, UIApplicationDelegate { + public var window: UIWindow? + public private(set) var windowWidth: CGFloat = UIScreen.main.bounds.size.width + public private(set) var windowHeight: CGFloat = UIScreen.main.bounds.size.height + + public func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + window = windowScene.keyWindow + } + + override public init() { + super.init() + windowWidth = window?.bounds.size.width ?? UIScreen.main.bounds.size.width + windowHeight = window?.bounds.size.height ?? UIScreen.main.bounds.size.height + Self.observedSceneDelegate.insert(self) + _ = Self.observer // just for activating the lazy static property + } + + deinit { + Task { @MainActor in + Self.observedSceneDelegate.remove(self) + } + } + + private static var observedSceneDelegate: Set = [] + private static let observer = Task { + while true { + try? await Task.sleep(for: .seconds(0.1)) + for delegate in observedSceneDelegate { + let newWidth = delegate.window?.bounds.size.width ?? UIScreen.main.bounds.size.width + if delegate.windowWidth != newWidth { + delegate.windowWidth = newWidth + } + let newHeight = delegate.window?.bounds.size.height ?? UIScreen.main.bounds.size.height + if delegate.windowHeight != newHeight { + delegate.windowHeight = newHeight + } + + } + } } } - -#Preview { - AppDelegate() -} diff --git a/Threaded/Components/CompactPostView.swift b/Threaded/Components/CompactPostView.swift index 220ff11..7ebc959 100644 --- a/Threaded/Components/CompactPostView.swift +++ b/Threaded/Components/CompactPostView.swift @@ -6,8 +6,10 @@ struct CompactPostView: View { @Environment(AccountManager.self) private var accountManager: AccountManager var status: Status @ObservedObject var navigator: Navigator + var pinned: Bool = false + @State private var preferences: UserPreferences = .defaultPreferences @State private var initialLike: Bool = false @State private var isLiked: Bool = false @State private var isReposted: Bool = false @@ -36,13 +38,18 @@ struct CompactPostView: View { .padding(.bottom, 3) } .onAppear { + do { + preferences = try UserPreferences.loadAsCurrent() ?? UserPreferences.defaultPreferences + } catch { + print(error) + } isLiked = status.reblog != nil ? status.reblog!.favourited ?? false : status.favourited ?? false initialLike = isLiked isReposted = status.reblog != nil ? status.reblog!.reblogged ?? false : status.reblogged ?? false - let likeCount: Int = status.favouritesCount - (initialLike ? 1 : 0) - let incrLike: Int = isLiked ? 1 : 0 - print("original: \(status.favouritesCount)\nmin1: \(likeCount)\nincr1: \(likeCount + incrLike)") +// let likeCount: Int = status.favouritesCount - (initialLike ? 1 : 0) +// let incrLike: Int = isLiked ? 1 : 0 +// print("original: \(status.favouritesCount)\nmin1: \(likeCount)\nincr1: \(likeCount + incrLike)") } .task { await loadEmbeddedStatus() @@ -81,7 +88,7 @@ struct CompactPostView: View { func statusPost(_ status: AnyStatus) -> some View { HStack(alignment: .top, spacing: 0) { // MARK: Profile picture - if status.repliesCount > 0 { + if status.repliesCount > 0 && preferences.experimental.replySymbol { VStack { profilePicture .onTapGesture { @@ -130,10 +137,26 @@ struct CompactPostView: View { .font(.callout) } - if status.card != nil { + if status.card != nil && status.mediaAttachments.isEmpty { PostCardView(card: status.card!) } + if !status.mediaAttachments.isEmpty { + if status.mediaAttachments.count > 1 { + ScrollView(.horizontal) { + HStack(alignment: .firstTextBaseline, spacing: 5) { + ForEach(status.mediaAttachments) { attachment in + PostAttachment(attachment: attachment) + } + } + } + .scrollIndicators(.hidden) + .scrollClipDisabled() + } else { + PostAttachment(attachment: status.mediaAttachments.first!) + } + } + // if hasQuote { // if quoteStatus != nil { // QuotePostView(status: quoteStatus!) @@ -328,14 +351,3 @@ struct CompactPostView: View { .tint(Color(uiColor: UIColor.label)) } } - -#Preview { - ScrollView { - VStack { - ForEach(Status.placeholders()) { status in - CompactPostView(status: status, navigator: Navigator()) - .environment(Client.init(server: AppInfo.defaultServer)) - } - } - } -} diff --git a/Threaded/Components/ListStyle.swift b/Threaded/Components/ListStyle.swift index 9195cec..6ffbbc9 100644 --- a/Threaded/Components/ListStyle.swift +++ b/Threaded/Components/ListStyle.swift @@ -2,12 +2,17 @@ import SwiftUI -struct ListStyle: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) +extension View { + func listThreaded() -> some View { + self + .scrollContentBackground(.hidden) + .tint(Color.white) + .background(Color.appBackground) + .listStyle(.inset) + } + func listRowThreaded() -> some View { + self + .listRowSeparator(.hidden) + .listRowBackground(Color.appBackground) } } - -#Preview { - ListStyle() -} diff --git a/Threaded/Components/OnlineImage.swift b/Threaded/Components/OnlineImage.swift index 4018bbc..6b1a850 100644 --- a/Threaded/Components/OnlineImage.swift +++ b/Threaded/Components/OnlineImage.swift @@ -6,7 +6,8 @@ import NukeUI struct OnlineImage: View { var url: URL? - var size: CGFloat = 500 + var size: CGFloat? = 500 + var maxSize: CGFloat? = 500 var priority: ImageRequest.Priority = .normal var useNuke: Bool = true @@ -18,6 +19,7 @@ struct OnlineImage: View { .resizable() .scaledToFit() .aspectRatio(1.0, contentMode: .fit) + .frame(idealWidth: size, maxWidth: maxSize) } else if state.error != nil { ContentUnavailableView("error.loading-image", systemImage: "rectangle.slash") } else { @@ -30,14 +32,14 @@ struct OnlineImage: View { } } .priority(priority) - .processors([.resize(width: size)]) + .processors([.resize(width: size ?? 500)]) } else { AsyncImage(url: url) { element in element .resizable() .scaledToFit() .aspectRatio(1.0, contentMode: .fit) - .frame(width: size) + .frame(minWidth: size, maxWidth: maxSize, alignment: .topLeading) } placeholder: { Rectangle() .fill(Color.gray) @@ -72,6 +74,40 @@ struct OnlineImage: View { self.useNuke = true } + init(url: URL? = nil, maxSize: CGFloat, useNuke: Bool) { + self.url = url + self.maxSize = maxSize + self.size = nil + self.priority = .normal + self.useNuke = useNuke + } + + /// Creates a new OnlineImage using Nuke, using the selected priority + init(url: URL? = nil, maxSize: CGFloat, priority: ImageRequest.Priority) { + self.url = url + self.maxSize = maxSize + self.size = nil + self.priority = priority + self.useNuke = true + } + + init(url: URL? = nil, size: CGFloat, maxSize: CGFloat, useNuke: Bool) { + self.url = url + self.maxSize = maxSize + self.size = size + self.priority = .normal + self.useNuke = useNuke + } + + /// Creates a new OnlineImage using Nuke, using the selected priority + init(url: URL? = nil, size: CGFloat, maxSize: CGFloat, priority: ImageRequest.Priority) { + self.url = url + self.maxSize = maxSize + self.size = size + self.priority = priority + self.useNuke = true + } + /// Change the priority of the Nuke OnlineImage mutating func setPriority(_ priority: ImageRequest.Priority) { guard self.useNuke == true else { return } diff --git a/Threaded/Components/PostAttachment.swift b/Threaded/Components/PostAttachment.swift index 001b229..ce8b734 100644 --- a/Threaded/Components/PostAttachment.swift +++ b/Threaded/Components/PostAttachment.swift @@ -1,13 +1,158 @@ //Made by Lumaa import SwiftUI +import UIKit +import AVKit struct PostAttachment: View { + @Environment(AppDelegate.self) private var appDelegate: AppDelegate + var attachment: MediaAttachment + @State private var player: AVPlayer? + + var appLayoutWidth: CGFloat = 10 + var availableWidth: CGFloat { + appDelegate.windowWidth * 0.8 + } + var availableHeight: CGFloat { + appDelegate.windowHeight + } + private let imageMaxHeight: CGFloat = 300 + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + let mediaSize: CGSize = size(for: attachment) ?? .init(width: imageMaxHeight, height: imageMaxHeight) + let newSize = imageSize(from: mediaSize) + + GeometryReader { _ in + // Audio later because it's a lil harder + if attachment.supportedType == .image { + if let url = attachment.url { + AsyncImage(url: url) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: newSize.width, height: newSize.height) + .overlay( + RoundedRectangle(cornerRadius: 15) + .stroke(.gray.opacity(0.3), lineWidth: 1) + ) + } placeholder: { + ZStack(alignment: .center) { + Color.gray + + ProgressView() + .progressViewStyle(.circular) + } + } + } + } else if attachment.supportedType == .gifv { + ZStack(alignment: .center) { + if player != nil { + NoControlsPlayerViewController(player: player!) + .overlay( + RoundedRectangle(cornerRadius: 15) + .stroke(.gray.opacity(0.3), lineWidth: 1) + ) + } else { + Color.gray + + ProgressView() + .progressViewStyle(.circular) + } + } + .onAppear { + if let url = attachment.url { + player = AVPlayer(url: url) + player?.audiovisualBackgroundPlaybackPolicy = .pauses + player?.isMuted = true + player?.play() + + guard let player else { return } + NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in + Task { @MainActor in + player.seek(to: CMTime.zero) + player.play() + } + } + } + } + + } else if attachment.supportedType == .video { + ZStack { + if player != nil { + VideoPlayer(player: player) + .overlay( + RoundedRectangle(cornerRadius: 15) + .stroke(.gray.opacity(0.3), lineWidth: 1) + ) + } else { + Color.gray + + ProgressView() + .progressViewStyle(.circular) + } + } + .onAppear { + if let url = attachment.url { + player = AVPlayer(url: url) + player?.audiovisualBackgroundPlaybackPolicy = .pauses + player?.isMuted = false + player?.play() + } + } + } + } + .frame(width: newSize.width, height: newSize.height) + .clipped() + .clipShape(.rect(cornerRadius: 15)) + .contentShape(Rectangle()) + } + + private func size(for media: MediaAttachment) -> CGSize? { + guard let width = media.meta?.original?.width, + let height = media.meta?.original?.height + else { return nil } + + return .init(width: CGFloat(width), height: CGFloat(height)) + } + + private func imageSize(from: CGSize) -> CGSize { + let boxWidth = availableWidth - appLayoutWidth + let boxHeight = availableHeight * 0.8 // use only 80% of window height to leave room for text + + if from.width <= boxWidth, from.height <= boxHeight { + // intrinsic size of image fits just fine + return from + } + + // shrink image proportionally to fit inside the box + let xRatio = boxWidth / from.width + let yRatio = boxHeight / from.height + if xRatio < yRatio { + return .init(width: boxWidth, height: from.height * xRatio) + } else { + return .init(width: from.width * yRatio, height: boxHeight) + } } } -#Preview { - PostAttachment() +class NoControlsAVPlayerViewController: AVPlayerViewController { + override func viewDidLoad() { + super.viewDidLoad() + self.showsPlaybackControls = false + } +} + +// Create a UIViewRepresentable for the customized AVPlayerViewController +struct NoControlsPlayerViewController: UIViewControllerRepresentable { + let player: AVPlayer + + func updateUIViewController(_ uiViewController: NoControlsAVPlayerViewController, context: Context) { + // update + } + + func makeUIViewController(context: Context) -> NoControlsAVPlayerViewController { + let customPlayerVC = NoControlsAVPlayerViewController() + customPlayerVC.player = player // Set the AVPlayer + return customPlayerVC + } } diff --git a/Threaded/Components/QuotePostView.swift b/Threaded/Components/QuotePostView.swift index 3e499ec..7de49ee 100644 --- a/Threaded/Components/QuotePostView.swift +++ b/Threaded/Components/QuotePostView.swift @@ -13,7 +13,6 @@ struct QuotePostView: View { .frame(width: 250) .padding(.horizontal, 10) .clipShape(.rect(cornerRadius: 15)) - .fixedSize(horizontal: false, vertical: true) .overlay( RoundedRectangle(cornerRadius: 15) .stroke(.gray.opacity(0.3), lineWidth: 1) diff --git a/Threaded/Data/Navigator.swift b/Threaded/Data/Navigator.swift index 7f6b84a..71220e1 100644 --- a/Threaded/Data/Navigator.swift +++ b/Threaded/Data/Navigator.swift @@ -68,6 +68,7 @@ public enum SheetDestination: Identifiable { public enum RouterDestination: Hashable { case settings case privacy + case appearence case account(acc: Account) case about } @@ -80,6 +81,8 @@ extension View { SettingsView(navigator: navigator) case .privacy: PrivacyView() + case .appearence: + AppearenceView() case .account(let acc): AccountView(account: acc, navigator: navigator) case .about: diff --git a/Threaded/Data/UserPreferences.swift b/Threaded/Data/UserPreferences.swift index 02c3e31..745c411 100644 --- a/Threaded/Data/UserPreferences.swift +++ b/Threaded/Data/UserPreferences.swift @@ -1,3 +1,76 @@ //Made by Lumaa import Foundation + +@Observable +class UserPreferences: Codable, ObservableObject { + private static let saveKey: String = "threaded-preferences.user" + public static let defaultPreferences: UserPreferences = .init() + + // Final + var displayedName: DisplayedName = .username + var profilePictureShape: ProfilePictureShape = .circle + + // Experimental + var showExperimental: Bool = false + var experimental: UserPreferences.Experimental + + init(displayedName: DisplayedName = .username, profilePictureShape: ProfilePictureShape = .circle, showExperimental: Bool = false, experimental: UserPreferences.Experimental = .init()) { + self.displayedName = displayedName + self.profilePictureShape = profilePictureShape + + self.showExperimental = showExperimental + self.experimental = experimental + } + + @Observable + class Experimental: Codable, ObservableObject { + private static let saveKey: String = "threaded-preferences.experimental" + + var replySymbol: Bool = false + + init(replySymbol: Bool = false) { + self.replySymbol = replySymbol + } + + func saveAsCurrent() throws { + let encoder = JSONEncoder() + let json = try encoder.encode(self) + UserDefaults.standard.setValue(json, forKey: UserPreferences.Experimental.saveKey) + } + + static func loadAsCurrent() throws -> UserPreferences.Experimental? { + let decoder = JSONDecoder() + if let data = UserDefaults.standard.data(forKey: UserPreferences.Experimental.saveKey) { + let exp = try decoder.decode(UserPreferences.Experimental.self, from: data) + return exp + } + return nil + } + } + + func saveAsCurrent() throws { + let encoder = JSONEncoder() + let json = try encoder.encode(self) + UserDefaults.standard.setValue(json, forKey: UserPreferences.saveKey) + } + + static func loadAsCurrent() throws -> UserPreferences? { + let decoder = JSONDecoder() + if let data = UserDefaults.standard.data(forKey: UserPreferences.saveKey) { + let pref = try decoder.decode(UserPreferences.self, from: data) + return pref + } + return nil + } + + // Enums and other + + enum DisplayedName: Codable, CaseIterable { + case username, displayName, both + } + + enum ProfilePictureShape: Codable, CaseIterable { + case circle, rounded + } +} diff --git a/Threaded/Localizable.xcstrings b/Threaded/Localizable.xcstrings index adf36a1..b078dd8 100644 --- a/Threaded/Localizable.xcstrings +++ b/Threaded/Localizable.xcstrings @@ -35,7 +35,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Threaded is a very simple Mastodon client, that is meant to look like the newest social media Threads made by Meta Platforms. It integrates perfectly with your Mastodon account, and matches the Threads vibe, while having Mastodon-only features.\n\nThreaded is a 100% free, made in France using SwiftUI, [open-source](https://github.com/lumaa-dev/ThreadedApp), and doesn't violate [your privacy](https://apps.lumaa.fr/legal/privacy). [Learn more](https://apps.lumaa.fr/app/threaded)" + "value" : "Threaded is a very simple Mastodon client, that is meant to look like the newest social media Threads made by Meta Platforms. It integrates perfectly with your Mastodon account, and matches the Threads vibe, while having Mastodon-only features.\n\nThreaded is a 100% free, made in France using SwiftUI, [open-source](https://github.com/lumaa-dev/ThreadedApp), and doesn't violate [your privacy](https://apps.lumaa.fr/legal/privacy).\n\nThreaded is not related or affiliated to Meta Platforms." } } } @@ -134,6 +134,16 @@ } } }, + "experimental" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Experimental" + } + } + } + }, "Hello world" : { }, @@ -246,6 +256,99 @@ } } } + }, + "setting.appearence" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appearence" + } + } + } + }, + "setting.appearence.displayed-name" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Displayed Name" + } + } + } + }, + "setting.appearence.displayed-name.both" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Both" + } + } + } + }, + "setting.appearence.displayed-name.display-name" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Display Name" + } + } + } + }, + "setting.appearence.displayed-name.username" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Username" + } + } + } + }, + "setting.appearence.pfp-shape" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shape of Profile Pictures" + } + } + } + }, + "setting.appearence.pfp-shape.circle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Circle" + } + } + } + }, + "setting.appearence.pfp-shape.rounded" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rounded Rectangle" + } + } + } + }, + "setting.appearence.reply-symbols" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Reply Symbols" + } + } + } + }, + "setting.experimental.activate" : { + }, "setting.privacy" : { "localizations" : { @@ -267,6 +370,26 @@ } } }, + "settings.cancel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, + "settings.done" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + } + } + }, "status.favourites-%lld" : { "localizations" : { "en" : { @@ -458,4 +581,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Threaded/Views/ContentView.swift b/Threaded/Views/ContentView.swift index 47df210..d632610 100644 --- a/Threaded/Views/ContentView.swift +++ b/Threaded/Views/ContentView.swift @@ -3,6 +3,8 @@ import SwiftUI struct ContentView: View { + @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate + @State private var navigator = Navigator() @State private var sheet: SheetDestination? @State private var accountManager: AccountManager = AccountManager() @@ -46,6 +48,7 @@ struct ContentView: View { .withSheets(sheetDestination: $navigator.presentedSheet) .environment(accountManager) .environment(navigator) + .environment(appDelegate) .task { await recognizeAccount() } diff --git a/Threaded/Views/PostDetailsView.swift b/Threaded/Views/PostDetailsView.swift index 56fa72b..69be9f9 100644 --- a/Threaded/Views/PostDetailsView.swift +++ b/Threaded/Views/PostDetailsView.swift @@ -3,11 +3,280 @@ import SwiftUI struct PostDetailsView: View { + @Environment(Navigator.self) private var navigator: Navigator + @Environment(AccountManager.self) private var accountManager: AccountManager + + var status: Status + + @State private var initialLike: Bool = false + @State private var isLiked: Bool = false + @State private var isReposted: Bool = false + @State private var hasQuote: Bool = false + @State private var quoteStatus: Status? = nil + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + VStack { + statusPost(status, isMain: true) + } + } + + @ViewBuilder + func statusPost(_ status: AnyStatus, isMain: Bool = false) -> some View { + VStack { + HStack { + profilePicture + .onTapGesture { + navigator.navigate(to: .account(acc: status.account)) + } + + Text(status.account.username) + .multilineTextAlignment(.leading) + .bold() + .onTapGesture { + navigator.navigate(to: .account(acc: status.account)) + } + } + + VStack(alignment: .leading) { + // MARK: Status main content + VStack(alignment: .leading, spacing: 10) { + if !status.content.asRawText.isEmpty { + TextEmoji(status.content, emojis: status.emojis, language: status.language) + .multilineTextAlignment(.leading) + .frame(width: 300, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) + .font(.callout) + } + + if status.card != nil && status.mediaAttachments.isEmpty { + PostCardView(card: status.card!) + } + + if !status.mediaAttachments.isEmpty { + ForEach(status.mediaAttachments) { attachment in + PostAttachment(attachment: attachment) + } + } + +// if hasQuote { +// if quoteStatus != nil { +// QuotePostView(status: quoteStatus!) +// } else { +// ProgressView() +// .progressViewStyle(.circular) +// } +// } + } + + //MARK: Action buttons + HStack(spacing: 13) { + asyncActionButton(isLiked ? "heart.fill" : "heart") { + do { + try await likePost() + HapticManager.playHaptics(haptics: Haptic.tap) + } catch { + HapticManager.playHaptics(haptics: Haptic.error) + print("Error: \(error.localizedDescription)") + } + } + actionButton("bubble.right") { + print("reply") + navigator.presentedSheet = .post() + } + asyncActionButton(isReposted ? "bolt.horizontal.fill" : "bolt.horizontal") { + do { + try await repostPost() + HapticManager.playHaptics(haptics: Haptic.tap) + } catch { + HapticManager.playHaptics(haptics: Haptic.error) + print("Error: \(error.localizedDescription)") + } + } + ShareLink(item: URL(string: status.url ?? "https://joinmastodon.org/")!) { + Image(systemName: "square.and.arrow.up") + .font(.title2) + } + .tint(Color(uiColor: UIColor.label)) + } + .padding(.top) + + // MARK: Status stats + stats.padding(.top, 5) + } + } + } + + func likePost() async throws { + if let client = accountManager.getClient() { + guard client.isAuth else { fatalError("Client is not authenticated") } + let statusId: String = status.reblog != nil ? status.reblog!.id : status.id + let endpoint = !isLiked ? Statuses.favorite(id: statusId) : Statuses.unfavorite(id: statusId) + + isLiked = !isLiked + let newStatus: Status = try await client.post(endpoint: endpoint) + if isLiked != newStatus.favourited { + isLiked = newStatus.favourited ?? !isLiked + } + } + } + + func repostPost() async throws { + if let client = accountManager.getClient() { + guard client.isAuth else { fatalError("Client is not authenticated") } + let statusId: String = status.reblog != nil ? status.reblog!.id : status.id + let endpoint = !isReposted ? Statuses.reblog(id: statusId) : Statuses.unreblog(id: statusId) + + isReposted = !isReposted + let newStatus: Status = try await client.post(endpoint: endpoint) + if isReposted != newStatus.reblogged { + isReposted = newStatus.reblogged ?? !isReposted + } + } + } + + var pinnedNotice: some View { + HStack (alignment:.center, spacing: 5) { + Image(systemName: "pin.fill") + + Text("status.pinned") + } + .padding(.leading, 20) + .multilineTextAlignment(.leading) + .lineLimit(1) + .font(.caption) + .foregroundStyle(Color(uiColor: UIColor.label).opacity(0.3)) + } + + var repostNotice: some View { + HStack (alignment:.center, spacing: 5) { + Image(systemName: "bolt.horizontal") + + Text("status.reposted-by.\(status.account.username)") + } + .padding(.leading, 20) + .multilineTextAlignment(.leading) + .lineLimit(1) + .font(.caption) + .foregroundStyle(Color(uiColor: UIColor.label).opacity(0.3)) + } + + var profilePicture: some View { + if status.reblog != nil { + OnlineImage(url: status.reblog!.account.avatar, size: 50, useNuke: true) + .frame(width: 40, height: 40) + .padding(.horizontal) + .clipShape(.circle) + } else { + OnlineImage(url: status.account.avatar, size: 50, useNuke: true) + .frame(width: 40, height: 40) + .padding(.horizontal) + .clipShape(.circle) + } + } + + var stats: some View { + //MARK: I acknowledge the existance of a count bug here + if status.reblog == nil { + HStack { + if status.repliesCount > 0 { + Text("status.replies-\(status.repliesCount)") + .monospacedDigit() + .foregroundStyle(.gray) + } + + if status.repliesCount > 0 && (status.favouritesCount > 0 || isLiked) { + Text("•") + .foregroundStyle(.gray) + } + + if status.favouritesCount > 0 || isLiked { + let likeCount: Int = status.favouritesCount - (initialLike ? 1 : 0) + let incrLike: Int = isLiked ? 1 : 0 + Text("status.favourites-\(likeCount + incrLike)") + .monospacedDigit() + .foregroundStyle(.gray) + .contentTransition(.numericText(value: Double(likeCount + incrLike))) + .transaction { t in + t.animation = .default + } + } + } + } else { + HStack { + if status.reblog!.repliesCount > 0 { + Text("status.replies-\(status.reblog!.repliesCount)") + .monospacedDigit() + .foregroundStyle(.gray) + } + + if status.reblog!.repliesCount > 0 && (status.reblog!.favouritesCount > 0 || isLiked) { + Text("•") + .foregroundStyle(.gray) + } + + if status.reblog!.favouritesCount > 0 || isLiked { + let likeCount: Int = status.reblog!.favouritesCount - (initialLike ? 1 : 0) + let incrLike: Int = isLiked ? 1 : 0 + Text("status.favourites-\(likeCount + incrLike)") + .monospacedDigit() + .foregroundStyle(.gray) + .contentTransition(.numericText(value: Double(likeCount + incrLike))) + .transaction { t in + t.animation = .default + } + } + } + } + } + + private func embededStatusURL() -> URL? { + let content = status.content + if let client = accountManager.getClient() { + if !content.statusesURLs.isEmpty, let url = content.statusesURLs.first, client.hasConnection(with: url) { + return url + } + } + return nil + } + + func loadEmbeddedStatus() async { + guard let url = embededStatusURL(), let client = accountManager.getClient() else { hasQuote = false; return } + + do { + hasQuote = true + if url.absoluteString.contains(client.server), let id = Int(url.lastPathComponent) { + quoteStatus = try await client.get(endpoint: Statuses.status(id: String(id))) + } else { + let results: SearchResults = try await client.get(endpoint: Search.search(query: url.absoluteString, type: "statuses", offset: 0, following: nil), forceVersion: .v2) + quoteStatus = results.statuses.first + } + } catch { + hasQuote = false + quoteStatus = nil + } + } + + @ViewBuilder + func actionButton(_ image: String, action: @escaping () -> Void) -> some View { + Button { + action() + } label: { + Image(systemName: image) + .font(.title2) + } + .tint(Color(uiColor: UIColor.label)) + } + + @ViewBuilder + func asyncActionButton(_ image: String, action: @escaping () async -> Void) -> some View { + Button { + Task { + await action() + } + } label: { + Image(systemName: image) + .font(.title2) + } + .tint(Color(uiColor: UIColor.label)) } } - -#Preview { - PostDetailsView() -} diff --git a/Threaded/Views/PostingView.swift b/Threaded/Views/PostingView.swift index 4ea319d..4997e9e 100644 --- a/Threaded/Views/PostingView.swift +++ b/Threaded/Views/PostingView.swift @@ -7,6 +7,7 @@ import PhotosUI struct PostingView: View { @Environment(\.dismiss) private var dismiss @Environment(AccountManager.self) private var accountManager: AccountManager + @Environment(Navigator.self) private var navigator: Navigator var initialString: String = "" @State private var postText: NSMutableAttributedString = .init(string: "") @@ -87,9 +88,10 @@ struct PostingView: View { Task { if let client = accountManager.getClient() { postingStatus = true - try await client.post(endpoint: Statuses.postStatus(json: .init(status: postText.string, visibility: visibility))) + let postedStatus: Status = try await client.post(endpoint: Statuses.postStatus(json: .init(status: postText.string, visibility: visibility))) postingStatus = false dismiss() + // navigate to post } } } label: { diff --git a/Threaded/Views/Settings/AboutView.swift b/Threaded/Views/Settings/AboutView.swift index 0a80ab6..50b5118 100644 --- a/Threaded/Views/Settings/AboutView.swift +++ b/Threaded/Views/Settings/AboutView.swift @@ -3,6 +3,8 @@ import SwiftUI struct AboutView: View { + @ObservedObject private var userPreferences: UserPreferences = .defaultPreferences + var body: some View { List { NavigationLink { @@ -11,14 +13,33 @@ struct AboutView: View { Text("about.app") .tint(Color.blue) } - .listRowSeparator(.hidden) - .listRowBackground(Color.appBackground) + .listRowThreaded() + + Toggle("setting.experimental.activate", isOn: $userPreferences.showExperimental) + .listRowThreaded() + .onAppear { + do { + let oldPreferences = try UserPreferences.loadAsCurrent() ?? UserPreferences.defaultPreferences + + userPreferences.showExperimental = oldPreferences.showExperimental + } catch { + print(error) + } + } } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background(Color.appBackground) + .listThreaded() .navigationTitle("about") .navigationBarTitleDisplayMode(.inline) + .onDisappear { + do { + if !userPreferences.showExperimental { + userPreferences.experimental = .init() + } + try userPreferences.saveAsCurrent() + } catch { + print(error) + } + } } var aboutApp: some View { @@ -31,8 +52,7 @@ struct AboutView: View { } .padding(.horizontal) } - .scrollContentBackground(.hidden) - .background(Color.appBackground) + .listThreaded() .navigationTitle("about.app") .navigationBarTitleDisplayMode(.large) } diff --git a/Threaded/Views/Settings/AppearenceView.swift b/Threaded/Views/Settings/AppearenceView.swift index 0f0f89d..5cae12c 100644 --- a/Threaded/Views/Settings/AppearenceView.swift +++ b/Threaded/Views/Settings/AppearenceView.swift @@ -3,8 +3,103 @@ import SwiftUI struct AppearenceView: View { + @ObservedObject private var userPreferences: UserPreferences = .defaultPreferences + @Environment(Navigator.self) private var navigator: Navigator + @Environment(\.dismiss) private var dismiss + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + List { + Picker(LocalizedStringKey("setting.appearence.displayed-name"), selection: $userPreferences.displayedName) { + ForEach(UserPreferences.DisplayedName.allCases, id: \.self) { displayCase in + switch (displayCase) { + case .username: + Text("setting.appearence.displayed-name.username") + .tag(UserPreferences.DisplayedName.username) + case .displayName: + Text("setting.appearence.displayed-name.display-name") + .tag(UserPreferences.DisplayedName.displayName) + case .both: + Text("setting.appearence.displayed-name.both") + .tag(UserPreferences.DisplayedName.both) + } + } + } + .pickerStyle(.inline) + .listRowThreaded() + + Picker(LocalizedStringKey("setting.appearence.pfp-shape"), selection: $userPreferences.profilePictureShape) { + ForEach(UserPreferences.ProfilePictureShape.allCases, id: \.self) { displayCase in + switch (displayCase) { + case .circle: + Text("setting.appearence.pfp-shape.circle") + .tag(UserPreferences.ProfilePictureShape.circle) + case .rounded: + Text("setting.appearence.pfp-shape.rounded") + .tag(UserPreferences.ProfilePictureShape.rounded) + } + } + } + .pickerStyle(.inline) + .listRowThreaded() + + if userPreferences.showExperimental { + Section(header: Text("experimental")) { + Toggle(LocalizedStringKey("setting.appearence.reply-symbols"), isOn: $userPreferences.experimental.replySymbol) + .listRowThreaded() + } + } + } + .listThreaded() + .navigationTitle("setting.appearence") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden() + .onAppear { + do { + let oldPreferences = try UserPreferences.loadAsCurrent() ?? UserPreferences.defaultPreferences + + userPreferences.displayedName = oldPreferences.displayedName + userPreferences.profilePictureShape = oldPreferences.profilePictureShape + + userPreferences.experimental.replySymbol = oldPreferences.experimental.replySymbol + } catch { + print(error) + dismiss() + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + do { + let oldPreferences = try UserPreferences.loadAsCurrent() ?? UserPreferences.defaultPreferences + + userPreferences.displayedName = oldPreferences.displayedName + userPreferences.profilePictureShape = oldPreferences.profilePictureShape + + userPreferences.experimental.replySymbol = oldPreferences.experimental.replySymbol + + dismiss() + } catch { + print(error) + dismiss() + } + } label: { + Text("settings.cancel") + } + } + + ToolbarItem(placement: .primaryAction) { + Button { + do { + try userPreferences.saveAsCurrent() + dismiss() + } catch { + print(error) + } + } label: { + Text("settings.done") + } + } + } } } diff --git a/Threaded/Views/Settings/SettingsView.swift b/Threaded/Views/Settings/SettingsView.swift index 751079b..f64b6db 100644 --- a/Threaded/Views/Settings/SettingsView.swift +++ b/Threaded/Views/Settings/SettingsView.swift @@ -9,39 +9,42 @@ struct SettingsView: View { var body: some View { NavigationStack(path: $navigator.path) { List { - Button { - navigator.navigate(to: .about) - } label: { - Label("about", systemImage: "info.circle") + Section { + Button { + navigator.navigate(to: .about) + } label: { + Label("about", systemImage: "info.circle") + } + .listRowThreaded() + + Button { + navigator.navigate(to: .privacy) + } label: { + Label("setting.privacy", systemImage: "lock") + } + .listRowThreaded() + + Button { + navigator.navigate(to: .appearence) + } label: { + Label("setting.appearence", systemImage: "rectangle.3.group") + } + .listRowThreaded() + + Button { + AppAccount.clear() + sheet = .welcome + } label: { + Text("logout") + .foregroundStyle(.red) + } + .tint(Color.red) + .listRowThreaded() } - .listRowSeparator(.hidden) - .listRowBackground(Color.appBackground) - - Button { - navigator.navigate(to: .privacy) - } label: { - Label("setting.privacy", systemImage: "lock") - } - .listRowSeparator(.hidden) - .listRowBackground(Color.appBackground) - - Button { - AppAccount.clear() - sheet = .welcome - } label: { - Text("logout") - .foregroundStyle(.red) - } - .tint(Color.red) - .listRowSeparator(.hidden) - .listRowBackground(Color.appBackground) } .withAppRouter(navigator) .withCovers(sheetDestination: $sheet) - .scrollContentBackground(.hidden) - .tint(Color.white) - .background(Color.appBackground) - .listStyle(.inset) + .listThreaded() .navigationTitle("settings") .navigationBarTitleDisplayMode(.inline) }