Compare commits

..

No commits in common. "main" and "1.7.6" have entirely different histories.
main ... 1.7.6

767 changed files with 24585 additions and 107753 deletions

View file

@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "swift" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"

View file

@ -1,35 +0,0 @@
name: Validate Translations
on:
pull_request:
types: [synchronize, opened, reopened, labeled, unlabeled, edited]
jobs:
main:
name: Validate Translations
runs-on: macOS-latest
steps:
- name: git checkout
uses: actions/checkout@v3
- name: ruby versions
run: |
ruby --version
gem --version
bundler --version
- name: ruby setup
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3
bundler-cache: true
# additional steps here, if needed
- name: Clone SwiftPolyglot
run: git clone https://github.com/appdecostudio/SwiftPolyglot.git --branch 0.2.0
- name: Build and Run SwiftPolyglot
run: |
swift build --package-path ./SwiftPolyglot --configuration release
swift run --package-path ./SwiftPolyglot swiftpolyglot "en,eu,be,ca,zh-Hans,zh-Hant,nl,en-GB,fr,de,it,ja,ko,nb,pl,pt-BR,es,tr,uk"

View file

@ -13,7 +13,7 @@ import Models
import Network import Network
// Sample code was sending this from a thread to another, let asume @Sendable for this // Sample code was sending this from a thread to another, let asume @Sendable for this
extension NSExtensionContext: @unchecked @retroactive Sendable {} extension NSExtensionContext: @unchecked Sendable {}
final class ActionRequestHandler: NSObject, NSExtensionRequestHandling, Sendable { final class ActionRequestHandler: NSObject, NSExtensionRequestHandling, Sendable {
enum Error: Swift.Error { enum Error: Swift.Error {

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,12 @@
{ {
"originHash" : "b7af8c2ab18771d4cebfbeb66d91559df500516a12027cd67834b2a576eb3df0",
"pins" : [ "pins" : [
{ {
"identity" : "bodega", "identity" : "bodega",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/mergesort/Bodega", "location" : "https://github.com/mergesort/Bodega",
"state" : { "state" : {
"revision" : "bfd8871e9c2590d31b200e54c75428a71483afdf", "revision" : "f0554077c178088ba11557bbdbb71775cc6a1b84",
"version" : "2.1.3" "version" : "2.1.0"
}
},
{
"identity" : "buttonkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Dean151/ButtonKit",
"state" : {
"revision" : "d567519b297777c38dee56ef10201fef4962ff75",
"version" : "0.4.1"
} }
}, },
{ {
@ -24,17 +14,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/divadretlaw/EmojiText", "location" : "https://github.com/divadretlaw/EmojiText",
"state" : { "state" : {
"revision" : "174a7bc7bd75650ad1acb5679dbb754296093de0", "revision" : "a4ddf5077c241170e8ac0d3a9480c511e27c1ae9",
"version" : "4.0.0" "version" : "2.8.0"
}
},
{
"identity" : "giphy-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Giphy/giphy-ios-sdk",
"state" : {
"revision" : "fb61ec12738133eb3b9bf62ed11d1bf93d9b4b20",
"version" : "2.2.10"
} }
}, },
{ {
@ -43,16 +24,7 @@
"location" : "https://github.com/evgenyneu/keychain-swift", "location" : "https://github.com/evgenyneu/keychain-swift",
"state" : { "state" : {
"branch" : "master", "branch" : "master",
"revision" : "5e1b02b6a9dac2a759a1d5dbc175c86bd192a608" "revision" : "c1fde55798b164cad44b5e23cfa2f0f1ebcd76af"
}
},
{
"identity" : "libwebp-xcode",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/libwebp-Xcode",
"state" : {
"revision" : "b2b1d20a90b14d11f6ef4241da6b81c1d3f171e4",
"version" : "1.3.2"
} }
}, },
{ {
@ -60,8 +32,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/nicklockwood/LRUCache", "location" : "https://github.com/nicklockwood/LRUCache",
"state" : { "state" : {
"revision" : "542f0449556327415409ededc9c43a4bd0a397dc", "revision" : "6d2b5246c9c98dcd498552bb22f08d55b12a8371",
"version" : "1.0.7" "version" : "1.0.4"
} }
}, },
{ {
@ -69,17 +41,17 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/kean/Nuke", "location" : "https://github.com/kean/Nuke",
"state" : { "state" : {
"revision" : "0ead44350d2737db384908569c012fe67c421e4d", "revision" : "3f666f120b63ea7de57d42e9a7c9b47f8e7a290b",
"version" : "12.8.0" "version" : "12.1.6"
} }
}, },
{ {
"identity" : "purchases-ios", "identity" : "purchases-ios",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/RevenueCat/purchases-ios", "location" : "https://github.com/RevenueCat/purchases-ios.git",
"state" : { "state" : {
"revision" : "7d55b964114a3d4a76791227cdc28577617596db", "revision" : "4601c1e0c246f3d74094229737e894a9f2339e6a",
"version" : "4.43.2" "version" : "4.25.7"
} }
}, },
{ {
@ -96,35 +68,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/stephencelis/SQLite.swift.git", "location" : "https://github.com/stephencelis/SQLite.swift.git",
"state" : { "state" : {
"revision" : "a95fc6df17d108bd99210db5e8a9bac90fe984b8", "revision" : "7a2e3cd27de56f6d396e84f63beefd0267b55ccb",
"version" : "0.15.3" "version" : "0.14.1"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-cmark.git",
"state" : {
"revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53",
"version" : "0.5.0"
}
},
{
"identity" : "swift-markdown",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-markdown",
"state" : {
"revision" : "8f79cb175981458a0a27e76cb42fee8e17b1a993",
"version" : "0.5.0"
}
},
{
"identity" : "swiftsdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TelemetryDeck/SwiftSDK",
"state" : {
"revision" : "13a26cf125b70d695913eb9bea9f9b9c29da5790",
"version" : "2.3.0"
} }
}, },
{ {
@ -132,46 +77,28 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup.git", "location" : "https://github.com/scinfu/SwiftSoup.git",
"state" : { "state" : {
"revision" : "3c2c7e1e72b8abd96eafbae80323c5c1e5317437", "revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6",
"version" : "2.7.5" "version" : "2.6.1"
} }
}, },
{ {
"identity" : "swiftui-introspect", "identity" : "swiftui-introspect",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/siteline/swiftui-introspect", "location" : "https://github.com/siteline/SwiftUI-Introspect.git",
"state" : { "state" : {
"revision" : "668a65735751432b640260c56dfa621cec568368", "revision" : "9da0f9b7bffe96a7c98a0128f1e214f62728a39a",
"version" : "1.2.0" "version" : "0.11.1"
} }
}, },
{ {
"identity" : "wishkit-ios", "identity" : "swiftui-shimmer",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/wishkit/wishkit-ios.git", "location" : "https://github.com/markiv/SwiftUI-Shimmer",
"state" : { "state" : {
"revision" : "2b5eb8d1fb13706f8c14767a5239e34e403375f1", "revision" : "965a7cbcbf094cbcf22b9251a2323bdc3432e171",
"version" : "4.2.2" "version" : "1.1.0"
}
},
{
"identity" : "wishkit-ios-shared",
"kind" : "remoteSourceControl",
"location" : "https://github.com/wishkit/wishkit-ios-shared.git",
"state" : {
"revision" : "118c9c482e4ad57c65d664283516425b98616483",
"version" : "1.4.3"
}
},
{
"identity" : "wrappinghstack",
"kind" : "remoteSourceControl",
"location" : "https://github.com/dkk/WrappingHStack",
"state" : {
"revision" : "425d9488ba55f58f0b34498c64c054c77fc2a44b",
"version" : "2.2.11"
} }
} }
], ],
"version" : 3 "version" : 2
} }

View file

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E9DF41F929830FEC0003AAD2"
BuildableName = "IceCubesActionExtension.appex"
BlueprintName = "IceCubesActionExtension"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1600" LastUpgradeVersion = "1420"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@ -15,7 +15,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9" BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app" BuildableName = "IceCubesApp.app"
BlueprintName = "IceCubesApp" BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj"> ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference> </BuildableReference>
@ -45,7 +45,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9" BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app" BuildableName = "IceCubesApp.app"
BlueprintName = "IceCubesApp" BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj"> ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference> </BuildableReference>
@ -62,7 +62,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9" BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app" BuildableName = "IceCubesApp.app"
BlueprintName = "IceCubesApp" BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj"> ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference> </BuildableReference>

View file

@ -1,114 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9F7788C42BE652B1004E6BEF"
BuildableName = "IceCubesAppWidgetsExtensionExtension.appex"
BlueprintName = "IceCubesAppWidgetsExtensionExtension"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCWidgetKind"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetDefaultView"
value = "timeline"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetFamily"
value = "systemMedium"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -1,100 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9F2A5415296AB631009B2D7C"
BuildableName = "IceCubesNotifications.appex"
BlueprintName = "IceCubesNotifications"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<RemoteRunnable
runnableDebuggingMode = "1"
BundleIdentifier = "com.thomasricouard.IceCubesApp"
RemotePath = "/Users/dimillian/Library/Developer/CoreSimulator/Devices/8EF923D0-4CF1-49B6-B287-5F05AD5440C1/data/Containers/Bundle/Application/C447A1D1-9BC9-49C9-8FA5-130E8403972F/Ice Cubes.app">
</RemoteRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</MacroExpansion>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FAD858729743F7400496AB1"
BuildableName = "IceCubesShareExtension.appex"
BlueprintName = "IceCubesShareExtension"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -6,10 +6,8 @@ import Env
import Explore import Explore
import LinkPresentation import LinkPresentation
import Lists import Lists
import MediaUI
import Models import Models
import Notifications import Status
import StatusKit
import SwiftUI import SwiftUI
import Timeline import Timeline
@ -24,8 +22,6 @@ extension View {
AccountDetailView(account: account) AccountDetailView(account: account)
case let .accountSettingsWithAccount(account, appAccount): case let .accountSettingsWithAccount(account, appAccount):
AccountSettingsView(account: account, appAccount: appAccount) AccountSettingsView(account: account, appAccount: appAccount)
case let .accountMediaGridView(account, initialMedia):
AccountDetailMediaGridView(account: account, initialMediaStatuses: initialMedia)
case let .statusDetail(id): case let .statusDetail(id):
StatusDetailView(statusId: id) StatusDetailView(statusId: id)
case let .statusDetailWithStatus(status): case let .statusDetailWithStatus(status):
@ -35,20 +31,9 @@ extension View {
case let .conversationDetail(conversation): case let .conversationDetail(conversation):
ConversationDetailView(conversation: conversation) ConversationDetailView(conversation: conversation)
case let .hashTag(tag, accountId): case let .hashTag(tag, accountId):
TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)), TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)), scrollToTopSignal: .constant(0), canFilterTimeline: false)
pinnedFilters: .constant([]),
selectedTagGroup: .constant(nil),
canFilterTimeline: false)
case let .list(list): case let .list(list):
TimelineView(timeline: .constant(.list(list: list)), TimelineView(timeline: .constant(.list(list: list)), scrollToTopSignal: .constant(0), canFilterTimeline: false)
pinnedFilters: .constant([]),
selectedTagGroup: .constant(nil),
canFilterTimeline: false)
case let .linkTimeline(url, title):
TimelineView(timeline: .constant(.link(url: url, title: title)),
pinnedFilters: .constant([]),
selectedTagGroup: .constant(nil),
canFilterTimeline: false)
case let .following(id): case let .following(id):
AccountsListView(mode: .following(accountId: id)) AccountsListView(mode: .following(accountId: id))
case let .followers(id): case let .followers(id):
@ -60,23 +45,9 @@ extension View {
case let .accountsList(accounts): case let .accountsList(accounts):
AccountsListView(mode: .accountsList(accounts: accounts)) AccountsListView(mode: .accountsList(accounts: accounts))
case .trendingTimeline: case .trendingTimeline:
TimelineView(timeline: .constant(.trending), TimelineView(timeline: .constant(.trending), scrollToTopSignal: .constant(0), canFilterTimeline: false)
pinnedFilters: .constant([]),
selectedTagGroup: .constant(nil),
canFilterTimeline: false)
case let .trendingLinks(cards):
TrendingLinksListView(cards: cards)
case let .tagsList(tags): case let .tagsList(tags):
TagsListView(tags: tags) TagsListView(tags: tags)
case .notificationsRequests:
NotificationsRequestsListView()
case let .notificationForAccount(accountId):
NotificationsListView(lockedType: nil,
lockedAccountId: accountId)
case .blockedAccounts:
AccountsListView(mode: .blocked)
case .mutedAccounts:
AccountsListView(mode: .muted)
} }
} }
} }
@ -85,31 +56,19 @@ extension View {
sheet(item: sheetDestinations) { destination in sheet(item: sheetDestinations) { destination in
switch destination { switch destination {
case let .replyToStatusEditor(status): case let .replyToStatusEditor(status):
StatusEditor.MainView(mode: .replyTo(status: status)) StatusEditorView(mode: .replyTo(status: status))
.withEnvironments() .withEnvironments()
case let .newStatusEditor(visibility): case let .newStatusEditor(visibility):
StatusEditor.MainView(mode: .new(text: nil, visibility: visibility)) StatusEditorView(mode: .new(visibility: visibility))
.withEnvironments()
case let .prefilledStatusEditor(text, visibility):
StatusEditor.MainView(mode: .new(text: text, visibility: visibility))
.withEnvironments()
case let .imageURL(urls, visibility):
StatusEditor.MainView(mode: .imageURL(urls: urls, visibility: visibility))
.withEnvironments() .withEnvironments()
case let .editStatusEditor(status): case let .editStatusEditor(status):
StatusEditor.MainView(mode: .edit(status: status)) StatusEditorView(mode: .edit(status: status))
.withEnvironments() .withEnvironments()
case let .quoteStatusEditor(status): case let .quoteStatusEditor(status):
StatusEditor.MainView(mode: .quote(status: status)) StatusEditorView(mode: .quote(status: status))
.withEnvironments()
case let .quoteLinkStatusEditor(link):
StatusEditor.MainView(mode: .quoteLink(link: link))
.withEnvironments() .withEnvironments()
case let .mentionStatusEditor(account, visibility): case let .mentionStatusEditor(account, visibility):
StatusEditor.MainView(mode: .mention(account: account, visibility: visibility)) StatusEditorView(mode: .mention(account: account, visibility: visibility))
.withEnvironments()
case .listCreate:
ListCreateView()
.withEnvironments() .withEnvironments()
case let .listEdit(list): case let .listEdit(list):
ListEditView(list: list) ListEditView(list: list)
@ -130,64 +89,36 @@ extension View {
StatusEditHistoryView(statusId: status) StatusEditHistoryView(statusId: status)
.withEnvironments() .withEnvironments()
case .settings: case .settings:
SettingsTabs(isModal: true) SettingsTabs(popToRootTab: .constant(.settings))
.withEnvironments() .withEnvironments()
.preferredColorScheme(Theme.shared.selectedScheme == .dark ? .dark : .light) .preferredColorScheme(Theme.shared.selectedScheme == .dark ? .dark : .light)
case .accountPushNotficationsSettings: case .accountPushNotficationsSettings:
if let subscription = PushNotificationsService.shared.subscriptions.first(where: { $0.account.token == AppAccountsManager.shared.currentAccount.oauthToken }) { if let subscription = PushNotificationsService.shared.subscriptions.first(where: { $0.account.token == AppAccountsManager.shared.currentAccount.oauthToken }) {
NavigationSheet { PushNotificationsView(subscription: subscription) } PushNotificationsView(subscription: subscription)
.withEnvironments() .withEnvironments()
} else { } else {
EmptyView() EmptyView()
} }
case .about:
NavigationSheet { AboutView() }
.withEnvironments()
case .support:
NavigationSheet { SupportAppView() }
.withEnvironments()
case let .report(status): case let .report(status):
ReportView(status: status) ReportView(status: status)
.withEnvironments() .withEnvironments()
case let .shareImage(image, status): case let .shareImage(image, status):
ActivityView(image: image, status: status) ActivityView(image: image, status: status)
.withEnvironments()
case let .editTagGroup(tagGroup, onSaved): case let .editTagGroup(tagGroup, onSaved):
EditTagGroupView(tagGroup: tagGroup, onSaved: onSaved) EditTagGroupView(editingTagGroup: tagGroup, onSaved: onSaved)
.withEnvironments()
case .timelineContentFilter:
NavigationSheet { TimelineContentFilterView() }
.presentationDetents([.medium])
.presentationBackground(.thinMaterial)
.withEnvironments()
case .accountEditInfo:
EditAccountView()
.withEnvironments()
case .accountFiltersList:
FiltersListView()
.withEnvironments() .withEnvironments()
} }
} }
} }
func withEnvironments() -> some View { func withEnvironments() -> some View {
environment(CurrentAccount.shared) environmentObject(CurrentAccount.shared)
.environment(UserPreferences.shared) .environmentObject(UserPreferences.shared)
.environment(CurrentInstance.shared) .environmentObject(CurrentInstance.shared)
.environment(Theme.shared) .environmentObject(Theme.shared)
.environment(AppAccountsManager.shared) .environmentObject(AppAccountsManager.shared)
.environment(PushNotificationsService.shared) .environmentObject(PushNotificationsService.shared)
.environment(AppAccountsManager.shared.currentClient) .environmentObject(AppAccountsManager.shared.currentClient)
.environment(QuickLook.shared)
}
func withModelContainer() -> some View {
modelContainer(for: [
Draft.self,
LocalTimeline.self,
TagGroup.self,
RecentTag.self,
])
} }
} }
@ -224,15 +155,9 @@ struct ActivityView: UIViewControllerRepresentable {
} }
func makeUIViewController(context _: UIViewControllerRepresentableContext<ActivityView>) -> UIActivityViewController { func makeUIViewController(context _: UIViewControllerRepresentableContext<ActivityView>) -> UIActivityViewController {
UIActivityViewController(activityItems: [image, LinkDelegate(image: image, status: status)], return UIActivityViewController(activityItems: [image, LinkDelegate(image: image, status: status)],
applicationActivities: nil) applicationActivities: nil)
} }
func updateUIViewController(_: UIActivityViewController, context _: UIViewControllerRepresentableContext<ActivityView>) {} func updateUIViewController(_: UIActivityViewController, context _: UIViewControllerRepresentableContext<ActivityView>) {}
} }
extension URL: @retroactive Identifiable {
public var id: String {
absoluteString
}
}

View file

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.icecubesapp</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.user-fonts</key>
<array>
<string>app-usage</string>
</array>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.$(BUNDLE_ID_PREFIX).IceCubesApp</string>
</array>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(BUNDLE_ID_PREFIX).IceCubesApp</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,306 @@
import Account
import AppAccount
import AVFoundation
import DesignSystem
import Env
import KeychainSwift
import Network
import RevenueCat
import SwiftUI
import Timeline
@main
struct IceCubesApp: App {
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
@Environment(\.scenePhase) private var scenePhase
@StateObject private var appAccountsManager = AppAccountsManager.shared
@StateObject private var currentInstance = CurrentInstance.shared
@StateObject private var currentAccount = CurrentAccount.shared
@StateObject private var userPreferences = UserPreferences.shared
@StateObject private var pushNotificationsService = PushNotificationsService.shared
@StateObject private var watcher = StreamWatcher()
@StateObject private var quickLook = QuickLook()
@StateObject private var theme = Theme.shared
@StateObject private var sidebarRouterPath = RouterPath()
@State private var selectedTab: Tab = .timeline
@State private var popToRootTab: Tab = .other
@State private var sideBarLoadedTabs: Set<Tab> = Set()
@State private var isSupporter: Bool = false
private var availableTabs: [Tab] {
appAccountsManager.currentClient.isAuth ? Tab.loggedInTabs() : Tab.loggedOutTab()
}
var body: some Scene {
WindowGroup {
appView
.applyTheme(theme)
.onAppear {
setNewClientsInEnv(client: appAccountsManager.currentClient)
setupRevenueCat()
refreshPushSubs()
}
.environmentObject(appAccountsManager)
.environmentObject(appAccountsManager.currentClient)
.environmentObject(quickLook)
.environmentObject(currentAccount)
.environmentObject(currentInstance)
.environmentObject(userPreferences)
.environmentObject(theme)
.environmentObject(watcher)
.environmentObject(pushNotificationsService)
.environment(\.isSupporter, isSupporter)
.fullScreenCover(item: $quickLook.url, content: { url in
QuickLookPreview(selectedURL: url, urls: quickLook.urls)
.edgesIgnoringSafeArea(.bottom)
.background(TransparentBackground())
})
.onChange(of: pushNotificationsService.handledNotification) { notification in
if notification != nil {
pushNotificationsService.handledNotification = nil
if appAccountsManager.currentAccount.oauthToken?.accessToken != notification?.account.token.accessToken,
let account = appAccountsManager.availableAccounts.first(where:
{ $0.oauthToken?.accessToken == notification?.account.token.accessToken })
{
appAccountsManager.currentAccount = account
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
selectedTab = .notifications
pushNotificationsService.handledNotification = notification
}
} else {
selectedTab = .notifications
}
}
}
}
.commands {
appMenu
}
.onChange(of: scenePhase) { scenePhase in
handleScenePhase(scenePhase: scenePhase)
}
.onChange(of: appAccountsManager.currentClient) { newClient in
setNewClientsInEnv(client: newClient)
if newClient.isAuth {
watcher.watch(streams: [.user, .direct])
}
}
}
@ViewBuilder
private var appView: some View {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
sidebarView
} else {
tabBarView
}
}
private func badgeFor(tab: Tab) -> Int {
if tab == .notifications && selectedTab != tab,
let token = appAccountsManager.currentAccount.oauthToken
{
return watcher.unreadNotificationsCount + userPreferences.getNotificationsCount(for: token)
}
return 0
}
private var sidebarView: some View {
SideBarView(selectedTab: $selectedTab,
popToRootTab: $popToRootTab,
tabs: availableTabs,
routerPath: sidebarRouterPath)
{
GeometryReader { _ in
HStack(spacing: 0) {
ZStack {
if selectedTab == .profile {
ProfileTab(popToRootTab: $popToRootTab)
}
ForEach(availableTabs) { tab in
if tab == selectedTab || sideBarLoadedTabs.contains(tab) {
tab
.makeContentView(popToRootTab: $popToRootTab)
.opacity(tab == selectedTab ? 1 : 0)
.transition(.opacity)
.id("\(tab)\(appAccountsManager.currentAccount.id)")
.onAppear {
sideBarLoadedTabs.insert(tab)
}
} else {
EmptyView()
}
}
}
if appAccountsManager.currentClient.isAuth,
userPreferences.showiPadSecondaryColumn
{
Divider().edgesIgnoringSafeArea(.all)
notificationsSecondaryColumn
}
}
}
}.onChange(of: $appAccountsManager.currentAccount.id) { _ in
sideBarLoadedTabs.removeAll()
}
}
private var notificationsSecondaryColumn: some View {
NotificationsTab(popToRootTab: $popToRootTab, lockedType: nil)
.environment(\.isSecondaryColumn, true)
.frame(maxWidth: .secondaryColumnWidth)
.id(appAccountsManager.currentAccount.id)
}
private var tabBarView: some View {
TabView(selection: .init(get: {
selectedTab
}, set: { newTab in
if newTab == selectedTab {
/// Stupid hack to trigger onChange binding in tab views.
popToRootTab = .other
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
popToRootTab = selectedTab
}
}
HapticManager.shared.fireHaptic(of: .tabSelection)
SoundEffectManager.shared.playSound(of: .tabSelection)
selectedTab = newTab
DispatchQueue.main.async {
if selectedTab == .notifications,
let token = appAccountsManager.currentAccount.oauthToken
{
userPreferences.setNotification(count: 0, token: token)
watcher.unreadNotificationsCount = 0
}
}
})) {
ForEach(availableTabs) { tab in
tab.makeContentView(popToRootTab: $popToRootTab)
.tabItem {
if userPreferences.showiPhoneTabLabel {
tab.label
.labelStyle(TitleAndIconLabelStyle())
} else {
tab.label
.labelStyle(IconOnlyLabelStyle())
}
}
.tag(tab)
.badge(badgeFor(tab: tab))
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .tabBar)
}
}
.id(appAccountsManager.currentClient.id)
}
private func setNewClientsInEnv(client: Client) {
currentAccount.setClient(client: client)
currentInstance.setClient(client: client)
userPreferences.setClient(client: client)
Task {
await currentInstance.fetchCurrentInstance()
watcher.setClient(client: client, instanceStreamingURL: currentInstance.instance?.urls?.streamingApi)
watcher.watch(streams: [.user, .direct])
}
}
private func handleScenePhase(scenePhase: ScenePhase) {
switch scenePhase {
case .background:
watcher.stopWatching()
case .active:
watcher.watch(streams: [.user, .direct])
UIApplication.shared.applicationIconBadgeNumber = 0
Task {
await userPreferences.refreshServerPreferences()
}
default:
break
}
}
private func setupRevenueCat() {
Purchases.logLevel = .error
Purchases.configure(withAPIKey: "appl_JXmiRckOzXXTsHKitQiicXCvMQi")
Purchases.shared.getCustomerInfo { info, _ in
if info?.entitlements["Supporter"]?.isActive == true {
isSupporter = true
}
}
}
private func refreshPushSubs() {
PushNotificationsService.shared.requestPushNotifications()
}
@CommandsBuilder
private var appMenu: some Commands {
CommandGroup(replacing: .newItem) {
Button("menu.new-post") {
sidebarRouterPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
}
}
CommandGroup(replacing: .textFormatting) {
Menu("menu.font") {
Button("menu.font.bigger") {
if theme.fontSizeScale < 1.5 {
theme.fontSizeScale += 0.1
}
}
Button("menu.font.smaller") {
if theme.fontSizeScale > 0.5 {
theme.fontSizeScale -= 0.1
}
}
}
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
let themeObserver = ThemeObserverViewController(nibName: nil, bundle: nil)
func application(_: UIApplication,
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
{
try? AVAudioSession.sharedInstance().setCategory(.ambient)
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
return true
}
func application(_: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)
{
PushNotificationsService.shared.pushToken = deviceToken
Task {
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
await PushNotificationsService.shared.updateSubscriptions(forceCreate: false)
}
}
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {}
func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
if connectingSceneSession.role == .windowApplication {
configuration.delegateClass = SceneDelegate.self
}
return configuration
}
}
class ThemeObserverViewController: UIViewController {
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
print(traitCollection.userInterfaceStyle.rawValue)
}
}

View file

@ -1,180 +0,0 @@
import Account
import AppAccount
import AVFoundation
import DesignSystem
import Env
import KeychainSwift
import MediaUI
import Network
import RevenueCat
import StatusKit
import SwiftUI
import Timeline
@MainActor
struct AppView: View {
@Environment(AppAccountsManager.self) private var appAccountsManager
@Environment(UserPreferences.self) private var userPreferences
@Environment(Theme.self) private var theme
@Environment(StreamWatcher.self) private var watcher
@Environment(\.openWindow) var openWindow
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Binding var selectedTab: AppTab
@Binding var appRouterPath: RouterPath
@State var iosTabs = iOSTabs.shared
@State var sidebarTabs = SidebarTabs.shared
@State var selectedTabScrollToTop: Int = -1
var body: some View {
#if os(visionOS)
tabBarView
#else
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
sidebarView
} else {
tabBarView
}
#endif
}
var availableTabs: [AppTab] {
guard appAccountsManager.currentClient.isAuth else {
return AppTab.loggedOutTab()
}
if UIDevice.current.userInterfaceIdiom == .phone || horizontalSizeClass == .compact {
return iosTabs.tabs
} else if UIDevice.current.userInterfaceIdiom == .vision {
return AppTab.visionOSTab()
}
return sidebarTabs.tabs.map { $0.tab }
}
@ViewBuilder
var tabBarView: some View {
TabView(selection: .init(get: {
selectedTab
}, set: { newTab in
updateTab(with: newTab)
})) {
ForEach(availableTabs) { tab in
tab.makeContentView(selectedTab: $selectedTab)
.tabItem {
if userPreferences.showiPhoneTabLabel {
tab.label
.environment(\.symbolVariants, tab == selectedTab ? .fill : .none)
} else {
Image(systemName: tab.iconName)
}
}
.tag(tab)
.badge(badgeFor(tab: tab))
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .tabBar)
}
}
.id(appAccountsManager.currentClient.id)
.withSheetDestinations(sheetDestinations: $appRouterPath.presentedSheet)
.environment(\.selectedTabScrollToTop, selectedTabScrollToTop)
}
private func updateTab(with newTab: AppTab) {
if newTab == .post {
#if os(visionOS)
openWindow(value: WindowDestinationEditor.newStatusEditor(visibility: userPreferences.postVisibility))
#else
appRouterPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
#endif
return
}
HapticManager.shared.fireHaptic(.tabSelection)
SoundEffectManager.shared.playSound(.tabSelection)
if selectedTab == newTab {
selectedTabScrollToTop = newTab.rawValue
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
selectedTabScrollToTop = -1
}
} else {
selectedTabScrollToTop = -1
}
selectedTab = newTab
}
private func badgeFor(tab: AppTab) -> Int {
if tab == .notifications, selectedTab != tab,
let token = appAccountsManager.currentAccount.oauthToken
{
return watcher.unreadNotificationsCount + (userPreferences.notificationsCount[token] ?? 0)
}
return 0
}
#if !os(visionOS)
var sidebarView: some View {
SideBarView(selectedTab: .init(get: {
selectedTab
}, set: { newTab in
updateTab(with: newTab)
}), tabs: availableTabs)
{
HStack(spacing: 0) {
if #available(iOS 18.0, *) {
baseTabView
#if targetEnvironment(macCatalyst)
.tabViewStyle(.sidebarAdaptable)
.introspect(.tabView, on: .iOS(.v17, .v18)) { (tabview: UITabBarController) in
tabview.sidebar.isHidden = true
}
#else
.tabViewStyle(.tabBarOnly)
#endif
} else {
baseTabView
}
if horizontalSizeClass == .regular,
appAccountsManager.currentClient.isAuth,
userPreferences.showiPadSecondaryColumn
{
Divider().edgesIgnoringSafeArea(.all)
notificationsSecondaryColumn
}
}
}
.environment(appRouterPath)
.environment(\.selectedTabScrollToTop, selectedTabScrollToTop)
}
#endif
private var baseTabView: some View {
TabView(selection: $selectedTab) {
ForEach(availableTabs) { tab in
tab
.makeContentView(selectedTab: $selectedTab)
.toolbar(horizontalSizeClass == .regular ? .hidden : .visible, for: .tabBar)
.tabItem {
tab.label
}
.tag(tab)
}
}
#if !os(visionOS)
.introspect(.tabView, on: .iOS(.v17, .v18)) { (tabview: UITabBarController) in
tabview.tabBar.isHidden = horizontalSizeClass == .regular
tabview.customizableViewControllers = []
tabview.moreNavigationController.isNavigationBarHidden = true
}
#endif
}
var notificationsSecondaryColumn: some View {
NotificationsTab(selectedTab: .constant(.notifications)
, lockedType: nil)
.environment(\.isSecondaryColumn, true)
.frame(maxWidth: .secondaryColumnWidth)
.id(appAccountsManager.currentAccount.id)
}
}

View file

@ -1,70 +0,0 @@
import Env
import SwiftUI
extension IceCubesApp {
@CommandsBuilder
var appMenu: some Commands {
CommandGroup(replacing: .appSettings) {
Button("menu.settings") {
appRouterPath.presentedSheet = .settings
}
.keyboardShortcut(",", modifiers: .command)
}
CommandGroup(replacing: .newItem) {
Button("menu.new-window") {
openWindow(id: "MainWindow")
}
.keyboardShortcut("n", modifiers: .shift)
Button("menu.new-post") {
#if targetEnvironment(macCatalyst)
openWindow(value: WindowDestinationEditor.newStatusEditor(visibility: userPreferences.postVisibility))
#else
appRouterPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
#endif
}
.keyboardShortcut("n", modifiers: .command)
}
CommandGroup(replacing: .textFormatting) {
Menu("menu.font") {
Button("menu.font.bigger") {
if theme.fontSizeScale < 1.5 {
theme.fontSizeScale += 0.1
}
}
Button("menu.font.smaller") {
if theme.fontSizeScale > 0.5 {
theme.fontSizeScale -= 0.1
}
}
}
}
CommandMenu("tab.timeline") {
Button("timeline.latest") {
NotificationCenter.default.post(name: .refreshTimeline, object: nil)
}
.keyboardShortcut("r", modifiers: .command)
Button("timeline.home") {
NotificationCenter.default.post(name: .homeTimeline, object: nil)
}
.keyboardShortcut("h", modifiers: .shift)
Button("timeline.trending") {
NotificationCenter.default.post(name: .trendingTimeline, object: nil)
}
.keyboardShortcut("t", modifiers: .shift)
Button("timeline.federated") {
NotificationCenter.default.post(name: .federatedTimeline, object: nil)
}
.keyboardShortcut("f", modifiers: .shift)
Button("timeline.local") {
NotificationCenter.default.post(name: .localTimeline, object: nil)
}
.keyboardShortcut("l", modifiers: .shift)
}
CommandGroup(replacing: .help) {
Button("menu.help.github") {
let url = URL(string: "https://github.com/Dimillian/IceCubesApp/issues")!
UIApplication.shared.open(url)
}
}
}
}

View file

@ -1,169 +0,0 @@
import AppIntents
import Env
import MediaUI
import StatusKit
import SwiftUI
extension IceCubesApp {
var appScene: some Scene {
WindowGroup(id: "MainWindow") {
AppView(selectedTab: $selectedTab, appRouterPath: $appRouterPath)
.applyTheme(theme)
.onAppear {
setNewClientsInEnv(client: appAccountsManager.currentClient)
setupRevenueCat()
refreshPushSubs()
}
.environment(appAccountsManager)
.environment(appAccountsManager.currentClient)
.environment(quickLook)
.environment(currentAccount)
.environment(currentInstance)
.environment(userPreferences)
.environment(theme)
.environment(watcher)
.environment(pushNotificationsService)
.environment(appIntentService)
.environment(\.isSupporter, isSupporter)
.sheet(item: $quickLook.selectedMediaAttachment) { selectedMediaAttachment in
if #available(iOS 18.0, *) {
MediaUIView(selectedAttachment: selectedMediaAttachment,
attachments: quickLook.mediaAttachments)
.presentationBackground(.ultraThinMaterial)
.presentationCornerRadius(16)
.presentationSizing(.page)
.withEnvironments()
} else {
MediaUIView(selectedAttachment: selectedMediaAttachment,
attachments: quickLook.mediaAttachments)
.presentationBackground(.ultraThinMaterial)
.presentationCornerRadius(16)
.withEnvironments()
}
}
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in
if newValue != nil {
pushNotificationsService.handledNotification = nil
if appAccountsManager.currentAccount.oauthToken?.accessToken != newValue?.account.token.accessToken,
let account = appAccountsManager.availableAccounts.first(where:
{ $0.oauthToken?.accessToken == newValue?.account.token.accessToken })
{
appAccountsManager.currentAccount = account
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
selectedTab = .notifications
pushNotificationsService.handledNotification = newValue
}
} else {
selectedTab = .notifications
}
}
}
.onChange(of: appIntentService.handledIntent) { _, _ in
if let intent = appIntentService.handledIntent?.intent {
handleIntent(intent)
appIntentService.handledIntent = nil
}
}
.withModelContainer()
}
.commands {
appMenu
}
.onChange(of: scenePhase) { _, newValue in
handleScenePhase(scenePhase: newValue)
}
.onChange(of: appAccountsManager.currentClient) { _, newValue in
setNewClientsInEnv(client: newValue)
if newValue.isAuth {
watcher.watch(streams: [.user, .direct])
}
}
#if targetEnvironment(macCatalyst)
.windowResize()
#elseif os(visionOS)
.defaultSize(width: 800, height: 1200)
#endif
}
@SceneBuilder
var otherScenes: some Scene {
WindowGroup(for: WindowDestinationEditor.self) { destination in
Group {
switch destination.wrappedValue {
case let .newStatusEditor(visibility):
StatusEditor.MainView(mode: .new(text: nil, visibility: visibility))
case let .prefilledStatusEditor(text, visibility):
StatusEditor.MainView(mode: .new(text: text, visibility: visibility))
case let .editStatusEditor(status):
StatusEditor.MainView(mode: .edit(status: status))
case let .quoteStatusEditor(status):
StatusEditor.MainView(mode: .quote(status: status))
case let .replyToStatusEditor(status):
StatusEditor.MainView(mode: .replyTo(status: status))
case let .mentionStatusEditor(account, visibility):
StatusEditor.MainView(mode: .mention(account: account, visibility: visibility))
case let .quoteLinkStatusEditor(link):
StatusEditor.MainView(mode: .quoteLink(link: link))
case .none:
EmptyView()
}
}
.withEnvironments()
.environment(\.isCatalystWindow, true)
.environment(RouterPath())
.withModelContainer()
.applyTheme(theme)
.frame(minWidth: 300, minHeight: 400)
}
.defaultSize(width: 600, height: 800)
.windowResizability(.contentMinSize)
WindowGroup(for: WindowDestinationMedia.self) { destination in
Group {
switch destination.wrappedValue {
case let .mediaViewer(attachments, selectedAttachment):
MediaUIView(selectedAttachment: selectedAttachment,
attachments: attachments)
case .none:
EmptyView()
}
}
.withEnvironments()
.withModelContainer()
.applyTheme(theme)
.environment(\.isCatalystWindow, true)
.frame(minWidth: 300, minHeight: 400)
}
.defaultSize(width: 1200, height: 1000)
.windowResizability(.contentMinSize)
}
private func handleIntent(_: any AppIntent) {
if let postIntent = appIntentService.handledIntent?.intent as? PostIntent {
#if os(visionOS) || os(macOS)
openWindow(value: WindowDestinationEditor.prefilledStatusEditor(text: postIntent.content ?? "",
visibility: userPreferences.postVisibility))
#else
appRouterPath.presentedSheet = .prefilledStatusEditor(text: postIntent.content ?? "",
visibility: userPreferences.postVisibility)
#endif
} else if let tabIntent = appIntentService.handledIntent?.intent as? TabIntent {
selectedTab = tabIntent.tab.toAppTab
} else if let imageIntent = appIntentService.handledIntent?.intent as? PostImageIntent,
let urls = imageIntent.images?.compactMap({ $0.fileURL })
{
appRouterPath.presentedSheet = .imageURL(urls: urls,
visibility: userPreferences.postVisibility)
}
}
}
extension Scene {
func windowResize() -> some Scene {
if #available(iOS 18.0, *) {
return self.windowResizability(.contentSize)
} else {
return self.defaultSize(width: 1100, height: 1400)
}
}
}

View file

@ -1,128 +0,0 @@
import Account
import AppAccount
import AVFoundation
import DesignSystem
import Env
import KeychainSwift
import MediaUI
import Network
import RevenueCat
import StatusKit
import SwiftUI
import Timeline
import WishKit
@main
struct IceCubesApp: App {
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
@Environment(\.scenePhase) var scenePhase
@Environment(\.openWindow) var openWindow
@State var appAccountsManager = AppAccountsManager.shared
@State var currentInstance = CurrentInstance.shared
@State var currentAccount = CurrentAccount.shared
@State var userPreferences = UserPreferences.shared
@State var pushNotificationsService = PushNotificationsService.shared
@State var appIntentService = AppIntentService.shared
@State var watcher = StreamWatcher.shared
@State var quickLook = QuickLook.shared
@State var theme = Theme.shared
@State var selectedTab: AppTab = .timeline
@State var appRouterPath = RouterPath()
@State var isSupporter: Bool = false
var body: some Scene {
appScene
otherScenes
}
func setNewClientsInEnv(client: Client) {
currentAccount.setClient(client: client)
currentInstance.setClient(client: client)
userPreferences.setClient(client: client)
Task {
await currentInstance.fetchCurrentInstance()
watcher.setClient(client: client, instanceStreamingURL: currentInstance.instance?.urls?.streamingApi)
watcher.watch(streams: [.user, .direct])
}
}
func handleScenePhase(scenePhase: ScenePhase) {
switch scenePhase {
case .background:
watcher.stopWatching()
case .active:
watcher.watch(streams: [.user, .direct])
UNUserNotificationCenter.current().setBadgeCount(0)
userPreferences.reloadNotificationsCount(tokens: appAccountsManager.availableAccounts.compactMap(\.oauthToken))
Task {
await userPreferences.refreshServerPreferences()
}
default:
break
}
}
func setupRevenueCat() {
Purchases.logLevel = .error
Purchases.configure(withAPIKey: "appl_JXmiRckOzXXTsHKitQiicXCvMQi")
Purchases.shared.getCustomerInfo { info, _ in
if info?.entitlements["Supporter"]?.isActive == true {
isSupporter = true
}
}
}
func refreshPushSubs() {
PushNotificationsService.shared.requestPushNotifications()
}
}
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_: UIApplication,
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
{
try? AVAudioSession.sharedInstance().setCategory(.ambient, options: .mixWithOthers)
try? AVAudioSession.sharedInstance().setActive(true)
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
Telemetry.setup()
Telemetry.signal("app.launched")
WishKit.configure(with: "AF21AE07-3BA9-4FE2-BFB1-59A3B3941730")
return true
}
func application(_: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)
{
PushNotificationsService.shared.pushToken = deviceToken
Task {
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
await PushNotificationsService.shared.updateSubscriptions(forceCreate: false)
}
}
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {}
func application(_: UIApplication, didReceiveRemoteNotification _: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
UserPreferences.shared.reloadNotificationsCount(tokens: AppAccountsManager.shared.availableAccounts.compactMap(\.oauthToken))
return .noData
}
func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
if connectingSceneSession.role == .windowApplication {
configuration.delegateClass = SceneDelegate.self
}
return configuration
}
override func buildMenu(with builder: UIMenuBuilder) {
super.buildMenu(with: builder)
builder.remove(menu: .document)
builder.remove(menu: .toolbar)
builder.remove(menu: .sidebar)
}
}

View file

@ -0,0 +1,96 @@
import QuickLook
import SwiftUI
import UIKit
extension URL: Identifiable {
public var id: String {
absoluteString
}
}
struct QuickLookPreview: UIViewControllerRepresentable {
let selectedURL: URL
let urls: [URL]
func makeUIViewController(context _: Context) -> UIViewController {
return AppQLPreviewController(selectedURL: selectedURL, urls: urls)
}
func updateUIViewController(
_: UIViewController, context _: Context
) {}
}
class AppQLPreviewController: UIViewController {
let selectedURL: URL
let urls: [URL]
var qlController: QLPreviewController?
init(selectedURL: URL, urls: [URL]) {
self.selectedURL = selectedURL
self.urls = urls
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if qlController == nil {
qlController = QLPreviewController()
qlController?.dataSource = self
qlController?.delegate = self
qlController?.currentPreviewItemIndex = urls.firstIndex(of: selectedURL) ?? 0
present(qlController!, animated: true)
}
}
}
extension AppQLPreviewController: QLPreviewControllerDataSource {
func numberOfPreviewItems(in _: QLPreviewController) -> Int {
return urls.count
}
func previewController(_: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
return urls[index] as QLPreviewItem
}
}
extension AppQLPreviewController: QLPreviewControllerDelegate {
func previewController(_: QLPreviewController, editingModeFor _: QLPreviewItem) -> QLPreviewItemEditingMode {
.createCopy
}
func previewControllerWillDismiss(_: QLPreviewController) {
dismiss(animated: true)
}
func previewControllerDidDismiss(_: QLPreviewController) {
dismiss(animated: true)
}
}
struct TransparentBackground: UIViewControllerRepresentable {
public func makeUIViewController(context _: Context) -> UIViewController {
return TransparentController()
}
public func updateUIViewController(_: UIViewController, context _: Context) {}
class TransparentController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
}
override func willMove(toParent parent: UIViewController?) {
super.willMove(toParent: parent)
parent?.view?.backgroundColor = .clear
parent?.modalPresentationStyle = .overCurrentContext
}
}
}

View file

@ -2,14 +2,14 @@ import DesignSystem
import Env import Env
import Models import Models
import Network import Network
import StatusKit import Status
import SwiftUI import SwiftUI
public struct ReportView: View { public struct ReportView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@Environment(Client.self) private var client @EnvironmentObject private var client: Client
let status: Status let status: Status
@State private var commentText: String = "" @State private var commentText: String = ""
@ -35,38 +35,42 @@ public struct ReportView: View {
} }
.navigationTitle("report.title") .navigationTitle("report.title")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#if !os(visionOS) .scrollContentBackground(.hidden)
.scrollContentBackground(.hidden) .background(theme.secondaryBackgroundColor)
.background(theme.secondaryBackgroundColor) .scrollDismissesKeyboard(.immediately)
.scrollDismissesKeyboard(.immediately) .toolbar {
#endif ToolbarItem(placement: .navigationBarTrailing) {
.toolbar { Button {
ToolbarItem(placement: .navigationBarTrailing) { isSendingReport = true
Button { Task {
isSendingReport = true do {
Task { let _: ReportSent =
do { try await client.post(endpoint: Statuses.report(accountId: status.account.id,
let _: ReportSent = statusId: status.id,
try await client.post(endpoint: Statuses.report(accountId: status.account.id, comment: commentText))
statusId: status.id, dismiss()
comment: commentText)) isSendingReport = false
dismiss() } catch {
isSendingReport = false isSendingReport = false
} catch {
isSendingReport = false
}
}
} label: {
if isSendingReport {
ProgressView()
} else {
Text("report.action.send")
} }
} }
} label: {
if isSendingReport {
ProgressView()
} else {
Text("report.action.send")
}
} }
CancelToolbarItem()
} }
ToolbarItem(placement: .navigationBarLeading) {
Button {
dismiss()
} label: {
Text("action.cancel")
}
}
}
} }
} }
} }

View file

@ -1,49 +1,32 @@
import DesignSystem import DesignSystem
import Env import Env
import Models
import Observation
import SafariServices import SafariServices
import SwiftUI import SwiftUI
import AppAccount
import WebKit
extension View { extension View {
@MainActor func withSafariRouter() -> some View { func withSafariRouter() -> some View {
modifier(SafariRouter()) modifier(SafariRouter())
} }
} }
@MainActor
private struct SafariRouter: ViewModifier { private struct SafariRouter: ViewModifier {
@Environment(\.isSecondaryColumn) private var isSecondaryColumn: Bool @EnvironmentObject private var theme: Theme
@Environment(Theme.self) private var theme @EnvironmentObject private var preferences: UserPreferences
@Environment(UserPreferences.self) private var preferences @EnvironmentObject private var routerPath: RouterPath
@Environment(RouterPath.self) private var routerPath
@Environment(AppAccountsManager.self) private var appAccount
#if !os(visionOS) @StateObject private var safariManager = InAppSafariManager()
@State private var safariManager = InAppSafariManager()
#endif
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.environment(\.openURL, OpenURLAction { url in .environment(\.openURL, OpenURLAction { url in
// Open internal URL. // Open internal URL.
guard !isSecondaryColumn else { return .discarded } routerPath.handle(url: url)
return routerPath.handle(url: url)
}) })
.onOpenURL { url in .onOpenURL { url in
// Open external URL (from icecubesapp://) // Open external URL (from icecubesapp://)
guard !isSecondaryColumn else { return } let urlString = url.absoluteString.replacingOccurrences(of: "icecubesapp://", with: "https://")
if url.absoluteString == "icecubesapp://subclub" {
#if !os(visionOS)
safariManager.dismiss()
#endif
return
}
let urlString = url.absoluteString.replacingOccurrences(of: AppInfo.scheme, with: "https://")
guard let url = URL(string: urlString), url.host != nil else { return } guard let url = URL(string: urlString), url.host != nil else { return }
_ = routerPath.handleDeepLink(url: url) _ = routerPath.handle(url: url)
} }
.onAppear { .onAppear {
routerPath.urlHandler = { url in routerPath.urlHandler = { url in
@ -56,107 +39,72 @@ private struct SafariRouter: ViewModifier {
UIApplication.shared.open(url) UIApplication.shared.open(url)
return .handled return .handled
} }
} else if url.query()?.contains("callback=") == false,
url.host() == AppInfo.premiumInstance,
let accountName = appAccount.currentAccount.accountName {
let newURL = url.appending(queryItems: [
.init(name: "callback", value: "icecubesapp://subclub"),
.init(name: "id", value: "@\(accountName)")
])
#if !os(visionOS)
return safariManager.open(newURL)
#else
return .systemAction
#endif
} }
#if !targetEnvironment(macCatalyst) guard preferences.preferredBrowser == .inAppSafari, !ProcessInfo.processInfo.isiOSAppOnMac else { return .systemAction }
guard preferences.preferredBrowser == .inAppSafari else { return .systemAction } // SFSafariViewController only supports initial URLs with http:// or https:// schemes.
// SFSafariViewController only supports initial URLs with http:// or https:// schemes. guard let scheme = url.scheme, ["https", "http"].contains(scheme.lowercased()) else {
guard let scheme = url.scheme, ["https", "http"].contains(scheme.lowercased()) else {
return .systemAction
}
#if os(visionOS)
return .systemAction
#else
return safariManager.open(url)
#endif
#else
return .systemAction return .systemAction
#endif }
return safariManager.open(url)
} }
} }
#if !os(visionOS)
.background { .background {
WindowReader { window in WindowReader { window in
safariManager.windowScene = window.windowScene self.safariManager.windowScene = window.windowScene
} }
} }
#endif
} }
} }
#if !os(visionOS) private class InAppSafariManager: NSObject, ObservableObject, SFSafariViewControllerDelegate {
var windowScene: UIWindowScene?
let viewController: UIViewController = .init()
var window: UIWindow?
@MainActor @MainActor
@Observable private class InAppSafariManager: NSObject, SFSafariViewControllerDelegate { func open(_ url: URL) -> OpenURLAction.Result {
var windowScene: UIWindowScene? guard let windowScene = windowScene else { return .systemAction }
let viewController: UIViewController = .init()
var window: UIWindow?
@MainActor window = setupWindow(windowScene: windowScene)
func open(_ url: URL) -> OpenURLAction.Result {
guard let windowScene else { return .systemAction }
window = setupWindow(windowScene: windowScene) let configuration = SFSafariViewController.Configuration()
configuration.entersReaderIfAvailable = UserPreferences.shared.inAppBrowserReaderView
let configuration = SFSafariViewController.Configuration() let safari = SFSafariViewController(url: url, configuration: configuration)
configuration.entersReaderIfAvailable = UserPreferences.shared.inAppBrowserReaderView safari.preferredBarTintColor = UIColor(Theme.shared.primaryBackgroundColor)
safari.preferredControlTintColor = UIColor(Theme.shared.tintColor)
safari.delegate = self
let safari = SFSafariViewController(url: url, configuration: configuration) DispatchQueue.main.async { [weak self] in
safari.preferredBarTintColor = UIColor(Theme.shared.primaryBackgroundColor) self?.viewController.present(safari, animated: true)
safari.preferredControlTintColor = UIColor(Theme.shared.tintColor)
safari.delegate = self
DispatchQueue.main.async { [weak self] in
self?.viewController.present(safari, animated: true)
}
return .handled
} }
func dismiss() { return .handled
viewController.presentedViewController?.dismiss(animated: true)
window?.resignKey()
window?.isHidden = false
window = nil
}
func setupWindow(windowScene: UIWindowScene) -> UIWindow {
let window = window ?? UIWindow(windowScene: windowScene)
window.rootViewController = viewController
window.makeKeyAndVisible()
switch Theme.shared.selectedScheme {
case .dark:
window.overrideUserInterfaceStyle = .dark
case .light:
window.overrideUserInterfaceStyle = .light
}
self.window = window
return window
}
nonisolated func safariViewControllerDidFinish(_: SFSafariViewController) {
Task { @MainActor in
window?.resignKey()
window?.isHidden = false
window = nil
}
}
} }
#endif
func setupWindow(windowScene: UIWindowScene) -> UIWindow {
let window = self.window ?? UIWindow(windowScene: windowScene)
window.rootViewController = viewController
window.makeKeyAndVisible()
switch Theme.shared.selectedScheme {
case .dark:
window.overrideUserInterfaceStyle = .dark
case .light:
window.overrideUserInterfaceStyle = .light
}
self.window = window
return window
}
func safariViewControllerDidFinish(_: SFSafariViewController) {
window?.resignKey()
window?.isHidden = false
window = nil
}
}
private struct WindowReader: UIViewRepresentable { private struct WindowReader: UIViewRepresentable {
var onUpdate: (UIWindow) -> Void var onUpdate: (UIWindow) -> Void

View file

@ -4,61 +4,40 @@ import DesignSystem
import Env import Env
import Models import Models
import SwiftUI import SwiftUI
import SwiftUIIntrospect
@MainActor
struct SideBarView<Content: View>: View { struct SideBarView<Content: View>: View {
@Environment(\.openWindow) private var openWindow @EnvironmentObject private var appAccounts: AppAccountsManager
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var userPreferences: UserPreferences
@Environment(AppAccountsManager.self) private var appAccounts @Binding var selectedTab: Tab
@Environment(CurrentAccount.self) private var currentAccount @Binding var popToRootTab: Tab
@Environment(Theme.self) private var theme var tabs: [Tab]
@Environment(StreamWatcher.self) private var watcher @ObservedObject var routerPath = RouterPath()
@Environment(UserPreferences.self) private var userPreferences
@Environment(RouterPath.self) private var routerPath
@Binding var selectedTab: AppTab
var tabs: [AppTab]
@ViewBuilder var content: () -> Content @ViewBuilder var content: () -> Content
@State private var sidebarTabs = SidebarTabs.shared private func badgeFor(tab: Tab) -> Int {
if tab == .notifications && selectedTab != tab,
private func badgeFor(tab: AppTab) -> Int {
if tab == .notifications, selectedTab != tab,
let token = appAccounts.currentAccount.oauthToken let token = appAccounts.currentAccount.oauthToken
{ {
return watcher.unreadNotificationsCount + (userPreferences.notificationsCount[token] ?? 0) return watcher.unreadNotificationsCount + userPreferences.getNotificationsCount(for: token)
} }
return 0 return 0
} }
private func makeIconForTab(tab: AppTab) -> some View { private func makeIconForTab(tab: Tab) -> some View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
HStack { SideBarIcon(systemIconName: tab.iconName,
SideBarIcon(systemIconName: tab.iconName, isSelected: tab == selectedTab)
isSelected: tab == selectedTab)
if userPreferences.isSidebarExpanded {
Text(tab.title)
.font(.headline)
.foregroundColor(tab == selectedTab ? theme.tintColor : theme.labelColor)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(width: (userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth) - 24, height: 50)
.background(tab == selectedTab ? theme.primaryBackgroundColor : .clear,
in: RoundedRectangle(cornerRadius: 8))
.cornerRadius(8)
.shadow(color: tab == selectedTab ? .black.opacity(0.2) : .clear, radius: 5)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(tab == selectedTab ? theme.labelColor.opacity(0.1) : .clear, lineWidth: 1)
)
let badge = badgeFor(tab: tab) let badge = badgeFor(tab: tab)
if badge > 0 { if badge > 0 {
makeBadgeView(count: badge) makeBadgeView(count: badge)
} }
} }
.contentShape(Rectangle())
.frame(width: .sidebarWidth, height: 50)
} }
private func makeBadgeView(count: Int) -> some View { private func makeBadgeView(count: Int) -> some View {
@ -70,32 +49,27 @@ struct SideBarView<Content: View>: View {
.font(.caption2) .font(.caption2)
} }
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
.offset(x: 5, y: -5) .offset(x: 14, y: -14)
} }
private var postButton: some View { private var postButton: some View {
Button { Button {
#if targetEnvironment(macCatalyst) || os(visionOS) routerPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
openWindow(value: WindowDestinationEditor.newStatusEditor(visibility: userPreferences.postVisibility))
#else
routerPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
#endif
} label: { } label: {
Image(systemName: "square.and.pencil") Image(systemName: "square.and.pencil")
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 20, height: 30) .frame(width: 20, height: 30)
.offset(x: 2, y: -2)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.help(AppTab.post.title) .keyboardShortcut("n", modifiers: .command)
} }
private func makeAccountButton(account: AppAccount, showBadge: Bool) -> some View { private func makeAccountButton(account: AppAccount, showBadge: Bool) -> some View {
Button { Button {
if account.id == appAccounts.currentAccount.id { if account.id == appAccounts.currentAccount.id {
selectedTab = .profile selectedTab = .profile
SoundEffectManager.shared.playSound(.tabSelection) SoundEffectManager.shared.playSound(of: .tabSelection)
} else { } else {
var transation = Transaction() var transation = Transaction()
transation.disablesAnimations = true transation.disablesAnimations = true
@ -105,114 +79,82 @@ struct SideBarView<Content: View>: View {
} }
} label: { } label: {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
if userPreferences.isSidebarExpanded { AppAccountView(viewModel: .init(appAccount: account, isCompact: true))
AppAccountView(viewModel: .init(appAccount: account, if showBadge,
isCompact: false,
isInSettings: false),
isParentPresented: .constant(false))
} else {
AppAccountView(viewModel: .init(appAccount: account,
isCompact: true,
isInSettings: false),
isParentPresented: .constant(false))
}
if !userPreferences.isSidebarExpanded,
showBadge,
let token = account.oauthToken, let token = account.oauthToken,
let notificationsCount = userPreferences.notificationsCount[token], userPreferences.getNotificationsCount(for: token) > 0
notificationsCount > 0
{ {
makeBadgeView(count: notificationsCount) makeBadgeView(count: userPreferences.getNotificationsCount(for: token))
} }
} }
.padding(.leading, userPreferences.isSidebarExpanded ? 16 : 0)
} }
.help(accountButtonTitle(accountName: account.accountName)) .frame(width: .sidebarWidth, height: 50)
.frame(width: userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth, height: 50)
.padding(.vertical, 8) .padding(.vertical, 8)
.background(selectedTab == .profile && account.id == appAccounts.currentAccount.id ? .background(selectedTab == .profile && account.id == appAccounts.currentAccount.id ?
theme.secondaryBackgroundColor : .clear) theme.secondaryBackgroundColor : .clear)
} }
private func accountButtonTitle(accountName: String?) -> LocalizedStringKey {
if let accountName {
"tab.profile-account-\(accountName)"
} else {
AppTab.profile.title
}
}
private var tabsView: some View { private var tabsView: some View {
ForEach(tabs) { tab in ForEach(tabs) { tab in
if tab != .profile && sidebarTabs.isEnabled(tab) { Button {
Button { // ensure keyboard is always dismissed when selecting a tab
// ensure keyboard is always dismissed when selecting a tab hideKeyboard()
hideKeyboard()
selectedTab = tab if tab == selectedTab {
SoundEffectManager.shared.playSound(.tabSelection) popToRootTab = .other
if tab == .notifications { DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
if let token = appAccounts.currentAccount.oauthToken { popToRootTab = tab
userPreferences.notificationsCount[token] = 0
}
watcher.unreadNotificationsCount = 0
} }
} label: {
makeIconForTab(tab: tab)
} }
.help(tab.title) selectedTab = tab
SoundEffectManager.shared.playSound(of: .tabSelection)
if tab == .notifications {
if let token = appAccounts.currentAccount.oauthToken {
userPreferences.setNotification(count: 0, token: token)
}
watcher.unreadNotificationsCount = 0
}
} label: {
makeIconForTab(tab: tab)
} }
.background(tab == selectedTab ? theme.secondaryBackgroundColor : .clear)
} }
} }
var body: some View { var body: some View {
@Bindable var routerPath = routerPath
HStack(spacing: 0) { HStack(spacing: 0) {
if horizontalSizeClass == .regular { ScrollView {
ScrollView { VStack(alignment: .center) {
VStack(alignment: .center) { if appAccounts.availableAccounts.isEmpty {
if appAccounts.availableAccounts.isEmpty { tabsView
tabsView } else {
} else { ForEach(appAccounts.availableAccounts) { account in
ForEach(appAccounts.availableAccounts) { account in makeAccountButton(account: account,
makeAccountButton(account: account, showBadge: account.id != appAccounts.currentAccount.id)
showBadge: account.id != appAccounts.currentAccount.id) if account.id == appAccounts.currentAccount.id {
if account.id == appAccounts.currentAccount.id { tabsView
tabsView
}
} }
} }
} }
postButton
.padding(.top, 12)
Spacer()
} }
.frame(width: userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth)
.scrollContentBackground(.hidden)
.background(.thinMaterial)
.safeAreaInset(edge: .bottom, content: {
HStack(spacing: 16) {
postButton
.padding(.vertical, 24)
.padding(.leading, userPreferences.isSidebarExpanded ? 18 : 0)
if userPreferences.isSidebarExpanded {
Text("menu.new-post")
.font(.subheadline)
.foregroundColor(theme.labelColor)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(width: userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth)
.background(.thinMaterial)
})
Divider().edgesIgnoringSafeArea(.all)
} }
.frame(width: .sidebarWidth)
.scrollContentBackground(.hidden)
.background(.thinMaterial)
Divider()
.edgesIgnoringSafeArea(.top)
content() content()
} }
.background(.thinMaterial) .background(.thinMaterial)
.edgesIgnoringSafeArea(.bottom)
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet) .withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
} }
} }
private struct SideBarIcon: View { private struct SideBarIcon: View {
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
let systemIconName: String let systemIconName: String
let isSelected: Bool let isSelected: Bool
@ -224,19 +166,17 @@ private struct SideBarIcon: View {
.font(.title2) .font(.title2)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(isSelected ? theme.tintColor : theme.labelColor) .foregroundColor(isSelected ? theme.tintColor : theme.labelColor)
.symbolVariant(isSelected ? .fill : .none)
.scaleEffect(isHovered ? 0.8 : 1.0) .scaleEffect(isHovered ? 0.8 : 1.0)
.onHover { isHovered in .onHover { isHovered in
withAnimation(.interpolatingSpring(stiffness: 300, damping: 15)) { withAnimation(.interpolatingSpring(stiffness: 300, damping: 15)) {
self.isHovered = isHovered self.isHovered = isHovered
} }
} }
.frame(width: 50, height: 40)
} }
} }
extension View { extension View {
@MainActor func hideKeyboard() { func hideKeyboard() {
let resign = #selector(UIResponder.resignFirstResponder) let resign = #selector(UIResponder.resignFirstResponder)
UIApplication.shared.sendAction(resign, to: nil, from: nil, for: nil) UIApplication.shared.sendAction(resign, to: nil, from: nil, for: nil)
} }

View file

@ -4,29 +4,44 @@ import Env
import Explore import Explore
import Models import Models
import Network import Network
import Shimmer
import SwiftUI import SwiftUI
@MainActor
struct ExploreTab: View { struct ExploreTab: View {
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@Environment(UserPreferences.self) private var preferences @EnvironmentObject private var preferences: UserPreferences
@Environment(CurrentAccount.self) private var currentAccount @EnvironmentObject private var currentAccount: CurrentAccount
@Environment(Client.self) private var client @EnvironmentObject private var client: Client
@State private var routerPath = RouterPath() @StateObject private var routerPath = RouterPath()
@Binding var popToRootTab: Tab
var body: some View { var body: some View {
NavigationStack(path: $routerPath.path) { NavigationStack(path: $routerPath.path) {
ExploreView() ExploreView()
.withAppRouter() .withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet) .withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar) .toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
.toolbar { .toolbar {
ToolbarTab(routerPath: $routerPath) statusEditorToolbarItem(routerPath: routerPath,
visibility: preferences.postVisibility)
if UIDevice.current.userInterfaceIdiom != .pad {
ToolbarItem(placement: .navigationBarLeading) {
AppAccountsSelectorView(routerPath: routerPath)
}
}
if UIDevice.current.userInterfaceIdiom == .pad && !preferences.showiPadSecondaryColumn {
SecondaryColumnToolbarItem()
}
} }
} }
.withSafariRouter() .withSafariRouter()
.environment(routerPath) .environmentObject(routerPath)
.onChange(of: client.id) { .onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
if popToRootTab == .explore {
routerPath.path = []
}
}
.onChange(of: client.id) { _ in
routerPath.path = [] routerPath.path = []
} }
.onAppear { .onAppear {

View file

@ -5,16 +5,17 @@ import DesignSystem
import Env import Env
import Models import Models
import Network import Network
import Shimmer
import SwiftUI import SwiftUI
@MainActor
struct MessagesTab: View { struct MessagesTab: View {
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@Environment(StreamWatcher.self) private var watcher @EnvironmentObject private var watcher: StreamWatcher
@Environment(Client.self) private var client @EnvironmentObject private var client: Client
@Environment(CurrentAccount.self) private var currentAccount @EnvironmentObject private var currentAccount: CurrentAccount
@Environment(AppAccountsManager.self) private var appAccount @EnvironmentObject private var appAccount: AppAccountsManager
@State private var routerPath = RouterPath() @StateObject private var routerPath = RouterPath()
@Binding var popToRootTab: Tab
var body: some View { var body: some View {
NavigationStack(path: $routerPath.path) { NavigationStack(path: $routerPath.path) {
@ -22,18 +23,27 @@ struct MessagesTab: View {
.withAppRouter() .withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet) .withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
.toolbar { .toolbar {
ToolbarTab(routerPath: $routerPath) if UIDevice.current.userInterfaceIdiom != .pad {
ToolbarItem(placement: .navigationBarLeading) {
AppAccountsSelectorView(routerPath: routerPath)
}
}
} }
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar) .toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
.id(client.id) .id(client.id)
} }
.onChange(of: client.id) { .onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
if popToRootTab == .messages {
routerPath.path = []
}
}
.onChange(of: client.id) { _ in
routerPath.path = [] routerPath.path = []
} }
.onAppear { .onAppear {
routerPath.client = client routerPath.client = client
} }
.withSafariRouter() .withSafariRouter()
.environment(routerPath) .environmentObject(routerPath)
} }
} }

View file

@ -1,24 +0,0 @@
import AppAccount
import DesignSystem
import Env
import SwiftUI
@MainActor
struct NavigationSheet<Content: View>: View {
@Environment(\.dismiss) private var dismiss
var content: () -> Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
var body: some View {
NavigationStack {
content()
.toolbar {
CloseToolbarItem()
}
}
}
}

View file

@ -1,46 +0,0 @@
import AppAccount
import DesignSystem
import Env
import Network
import SwiftUI
@MainActor
struct NavigationTab<Content: View>: View {
@Environment(\.isSecondaryColumn) private var isSecondaryColumn: Bool
@Environment(AppAccountsManager.self) private var appAccount
@Environment(CurrentAccount.self) private var currentAccount
@Environment(UserPreferences.self) private var userPreferences
@Environment(Theme.self) private var theme
@Environment(Client.self) private var client
var content: () -> Content
@State private var routerPath = RouterPath()
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
var body: some View {
NavigationStack(path: $routerPath.path) {
content()
.withEnvironments()
.withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
.withSafariRouter()
.toolbar {
ToolbarTab(routerPath: $routerPath)
}
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar)
.onChange(of: client.id) {
routerPath.path = []
}
.onAppear {
routerPath.client = client
}
.withSafariRouter()
}
.environment(routerPath)
}
}

View file

@ -7,21 +7,19 @@ import Notifications
import SwiftUI import SwiftUI
import Timeline import Timeline
@MainActor
struct NotificationsTab: View { struct NotificationsTab: View {
@Environment(\.isSecondaryColumn) private var isSecondaryColumn: Bool @Environment(\.isSecondaryColumn) private var isSecondaryColumn: Bool
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@Environment(Client.self) private var client @EnvironmentObject private var client: Client
@Environment(StreamWatcher.self) private var watcher @EnvironmentObject private var watcher: StreamWatcher
@Environment(AppAccountsManager.self) private var appAccount @EnvironmentObject private var appAccount: AppAccountsManager
@Environment(CurrentAccount.self) private var currentAccount @EnvironmentObject private var currentAccount: CurrentAccount
@Environment(UserPreferences.self) private var userPreferences @EnvironmentObject private var userPreferences: UserPreferences
@Environment(PushNotificationsService.self) private var pushNotificationsService @EnvironmentObject private var pushNotificationsService: PushNotificationsService
@State private var routerPath = RouterPath() @StateObject private var routerPath = RouterPath()
@Binding var popToRootTab: Tab
@Binding var selectedTab: AppTab
let lockedType: Models.Notification.NotificationType? let lockedType: Models.Notification.NotificationType?
@ -31,62 +29,70 @@ struct NotificationsTab: View {
.withAppRouter() .withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet) .withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { if !isSecondaryColumn {
Button { statusEditorToolbarItem(routerPath: routerPath,
routerPath.presentedSheet = .accountPushNotficationsSettings visibility: userPreferences.postVisibility)
} label: { if UIDevice.current.userInterfaceIdiom != .pad {
Image(systemName: "bell") ToolbarItem(placement: .navigationBarLeading) {
AppAccountsSelectorView(routerPath: routerPath)
}
}
}
if UIDevice.current.userInterfaceIdiom == .pad {
if (!isSecondaryColumn && !userPreferences.showiPadSecondaryColumn) || isSecondaryColumn {
SecondaryColumnToolbarItem()
} }
} }
ToolbarTab(routerPath: $routerPath)
} }
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar) .toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
.id(client.id) .id(client.id)
} }
.onAppear { .onAppear {
routerPath.client = client routerPath.client = client
clearNotifications() if isSecondaryColumn {
clearNotifications()
}
} }
.withSafariRouter() .withSafariRouter()
.environment(routerPath) .environmentObject(routerPath)
.onChange(of: selectedTab) { _, _ in .onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
clearNotifications() if popToRootTab == .notifications {
routerPath.path = []
}
} }
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in .onChange(of: pushNotificationsService.handledNotification) { notification in
if let newValue, let type = newValue.notification.supportedType { if let notification, let type = notification.notification.supportedType {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
switch type { switch type {
case .follow, .follow_request: case .follow, .follow_request:
routerPath.navigate(to: .accountDetailWithAccount(account: newValue.notification.account)) routerPath.navigate(to: .accountDetailWithAccount(account: notification.notification.account))
default: default:
if let status = newValue.notification.status { if let status = notification.notification.status {
routerPath.navigate(to: .statusDetailWithStatus(status: status)) routerPath.navigate(to: .statusDetailWithStatus(status: status))
} }
} }
} }
} }
} }
.onChange(of: scenePhase) { _, newValue in .onChange(of: scenePhase, perform: { scenePhase in
switch newValue { switch scenePhase {
case .active: case .active:
clearNotifications() clearNotifications()
default: default:
break break
} }
} })
.onChange(of: client.id) { .onChange(of: client.id) { _ in
routerPath.path = [] routerPath.path = []
} }
} }
private func clearNotifications() { private func clearNotifications() {
if selectedTab == .notifications || isSecondaryColumn { if isSecondaryColumn {
if let token = appAccount.currentAccount.oauthToken, userPreferences.notificationsCount[token] ?? 0 > 0 { if let token = appAccount.currentAccount.oauthToken {
userPreferences.notificationsCount[token] = 0 userPreferences.setNotification(count: 0, token: token)
}
if watcher.unreadNotificationsCount > 0 {
watcher.unreadNotificationsCount = 0
} }
watcher.unreadNotificationsCount = 0
} }
} }
} }

View file

@ -5,15 +5,16 @@ import DesignSystem
import Env import Env
import Models import Models
import Network import Network
import Shimmer
import SwiftUI import SwiftUI
@MainActor
struct ProfileTab: View { struct ProfileTab: View {
@Environment(AppAccountsManager.self) private var appAccount @EnvironmentObject private var appAccount: AppAccountsManager
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@Environment(Client.self) private var client @EnvironmentObject private var client: Client
@Environment(CurrentAccount.self) private var currentAccount @EnvironmentObject private var currentAccount: CurrentAccount
@State private var routerPath = RouterPath() @StateObject private var routerPath = RouterPath()
@Binding var popToRootTab: Tab
var body: some View { var body: some View {
NavigationStack(path: $routerPath.path) { NavigationStack(path: $routerPath.path) {
@ -21,21 +22,25 @@ struct ProfileTab: View {
AccountDetailView(account: account) AccountDetailView(account: account)
.withAppRouter() .withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet) .withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar) .toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
.id(account.id) .id(account.id)
} else { } else {
AccountDetailView(account: .placeholder()) AccountDetailView(account: .placeholder())
.redacted(reason: .placeholder) .redacted(reason: .placeholder)
.allowsHitTesting(false)
} }
} }
.onChange(of: client.id) { .onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
if popToRootTab == .profile {
routerPath.path = []
}
}
.onChange(of: client.id) { _ in
routerPath.path = [] routerPath.path = []
} }
.onAppear { .onAppear {
routerPath.client = client routerPath.client = client
} }
.withSafariRouter() .withSafariRouter()
.environment(routerPath) .environmentObject(routerPath)
} }
} }

View file

@ -1,18 +1,10 @@
import Account
import DesignSystem import DesignSystem
import Env import Env
import Models
import Network
import SwiftUI import SwiftUI
@MainActor
struct AboutView: View { struct AboutView: View {
@Environment(RouterPath.self) private var routerPath @EnvironmentObject private var routerPath: RouterPath
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@Environment(Client.self) private var client
@State private var dimillianAccount: AccountsListRowViewModel?
@State private var iceCubesAccount: AccountsListRowViewModel?
let versionNumber: String let versionNumber: String
@ -27,28 +19,27 @@ struct AboutView: View {
var body: some View { var body: some View {
List { List {
Section { Section {
#if !targetEnvironment(macCatalyst) && !os(visionOS) HStack {
HStack { Spacer()
Spacer() Image("icon0")
Image(uiImage: .init(named: "AppIconAlternate0-image") ?? .init()) .resizable()
.resizable() .frame(width: 50, height: 50)
.frame(width: 50, height: 50) .cornerRadius(4)
.cornerRadius(4) Image("icon14")
Image(uiImage: .init(named: "AppIconAlternate46-image") ?? .init()) .resizable()
.resizable() .frame(width: 50, height: 50)
.frame(width: 50, height: 50) .cornerRadius(4)
.cornerRadius(4) Image("icon17")
Image(uiImage: .init(named: "AppIconAlternate17-image") ?? .init()) .resizable()
.resizable() .frame(width: 50, height: 50)
.frame(width: 50, height: 50) .cornerRadius(4)
.cornerRadius(4) Image("icon23")
Image(uiImage: .init(named: "AppIconAlternate23-image") ?? .init()) .resizable()
.resizable() .frame(width: 50, height: 50)
.frame(width: 50, height: 50) .cornerRadius(4)
.cornerRadius(4) Spacer()
Spacer() }
}
#endif
Link(destination: URL(string: "https://github.com/Dimillian/IceCubesApp/blob/main/PRIVACY.MD")!) { Link(destination: URL(string: "https://github.com/Dimillian/IceCubesApp/blob/main/PRIVACY.MD")!) {
Label("settings.support.privacy-policy", systemImage: "lock") Label("settings.support.privacy-policy", systemImage: "lock")
} }
@ -57,25 +48,9 @@ struct AboutView: View {
Label("settings.support.terms-of-use", systemImage: "checkmark.shield") Label("settings.support.terms-of-use", systemImage: "checkmark.shield")
} }
} footer: { } footer: {
Text("\(versionNumber)© 2024 Thomas Ricouard") Text("\(versionNumber)©2023 Thomas Ricouard")
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
followAccountsSection
Section("Telemetry") {
Link(destination: .init(string: "https://telemetrydeck.com")!) {
Label("Telemetry by TelemetryDeck", systemImage: "link")
}
Link(destination: .init(string: "https://telemetrydeck.com/privacy/")!) {
Label("Privacy Policy", systemImage: "checkmark.shield")
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Section { Section {
Text(""" Text("""
@ -105,77 +80,27 @@ struct AboutView: View {
""") """)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.font(.scaledSubheadline) .font(.scaledSubheadline)
.foregroundStyle(.secondary) .foregroundColor(.gray)
} header: { } header: {
Text("settings.about.built-with") Text("settings.about.built-with")
.textCase(nil) .textCase(nil)
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
}
.task {
await fetchAccounts()
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
#if !os(visionOS) .scrollContentBackground(.hidden)
.scrollContentBackground(.hidden) .background(theme.secondaryBackgroundColor)
.background(theme.secondaryBackgroundColor) .navigationTitle(Text("settings.about.title"))
#endif .navigationBarTitleDisplayMode(.large)
.navigationTitle(Text("settings.about.title")) .environment(\.openURL, OpenURLAction { url in
.navigationBarTitleDisplayMode(.large) routerPath.handle(url: url)
.environment(\.openURL, OpenURLAction { url in })
routerPath.handle(url: url)
})
}
@ViewBuilder
private var followAccountsSection: some View {
if let iceCubesAccount, let dimillianAccount {
Section {
AccountsListRow(viewModel: iceCubesAccount)
AccountsListRow(viewModel: dimillianAccount)
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
} else {
Section {
ProgressView()
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
}
private func fetchAccounts() async {
await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
let viewModel = try await fetchAccountViewModel(client, account: "dimillian@mastodon.social")
await MainActor.run {
dimillianAccount = viewModel
}
}
group.addTask {
let viewModel = try await fetchAccountViewModel(client, account: "icecubesapp@mastodon.online")
await MainActor.run {
iceCubesAccount = viewModel
}
}
}
}
private func fetchAccountViewModel(_ client: Client, account: String) async throws -> AccountsListRowViewModel {
let dimillianAccount: Account = try await client.get(endpoint: Accounts.lookup(name: account))
let rel: [Relationship] = try await client.get(endpoint: Accounts.relationships(ids: [dimillianAccount.id]))
return .init(account: dimillianAccount, relationShip: rel.first)
} }
} }
struct AboutView_Previews: PreviewProvider { struct AboutView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
AboutView() AboutView()
.environment(Theme.shared) .environmentObject(Theme.shared)
} }
} }

View file

@ -11,16 +11,16 @@ struct AccountSettingsView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@Environment(PushNotificationsService.self) private var pushNotifications @EnvironmentObject private var pushNotifications: PushNotificationsService
@Environment(CurrentAccount.self) private var currentAccount @EnvironmentObject private var currentAccount: CurrentAccount
@Environment(CurrentInstance.self) private var currentInstance @EnvironmentObject private var currentInstance: CurrentInstance
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@Environment(AppAccountsManager.self) private var appAccountsManager @EnvironmentObject private var appAccountsManager: AppAccountsManager
@Environment(Client.self) private var client @EnvironmentObject private var client: Client
@Environment(RouterPath.self) private var routerPath
@State private var isEditingAccount: Bool = false
@State private var isEditingFilters: Bool = false
@State private var cachedPostsCount: Int = 0 @State private var cachedPostsCount: Int = 0
@State private var timelineCache = TimelineCache()
let account: Account let account: Account
let appAccount: AppAccount let appAccount: AppAccount
@ -29,7 +29,7 @@ struct AccountSettingsView: View {
Form { Form {
Section { Section {
Button { Button {
routerPath.presentedSheet = .accountEditInfo isEditingAccount = true
} label: { } label: {
Label("account.action.edit-info", systemImage: "pencil") Label("account.action.edit-info", systemImage: "pencil")
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@ -39,7 +39,7 @@ struct AccountSettingsView: View {
if currentInstance.isFiltersSupported { if currentInstance.isFiltersSupported {
Button { Button {
routerPath.presentedSheet = .accountFiltersList isEditingFilters = true
} label: { } label: {
Label("account.action.edit-filters", systemImage: "line.3.horizontal.decrease.circle") Label("account.action.edit-filters", systemImage: "line.3.horizontal.decrease.circle")
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@ -59,8 +59,8 @@ struct AccountSettingsView: View {
Label("settings.account.cached-posts-\(String(cachedPostsCount))", systemImage: "internaldrive") Label("settings.account.cached-posts-\(String(cachedPostsCount))", systemImage: "internaldrive")
Button("settings.account.action.delete-cache", role: .destructive) { Button("settings.account.action.delete-cache", role: .destructive) {
Task { Task {
await timelineCache.clearCache(for: appAccountsManager.currentClient.id) await TimelineCache.shared.clearCache(for: appAccountsManager.currentClient.id)
cachedPostsCount = await timelineCache.cachedPostsCount(for: appAccountsManager.currentClient.id) cachedPostsCount = await TimelineCache.shared.cachedPostsCount(for: appAccountsManager.currentClient.id)
} }
} }
} }
@ -80,38 +80,41 @@ struct AccountSettingsView: View {
if let token = appAccount.oauthToken { if let token = appAccount.oauthToken {
Task { Task {
let client = Client(server: appAccount.server, oauthToken: token) let client = Client(server: appAccount.server, oauthToken: token)
await timelineCache.clearCache(for: client.id) await TimelineCache.shared.clearCache(for: client.id)
if let sub = pushNotifications.subscriptions.first(where: { $0.account.token == token }) { if let sub = pushNotifications.subscriptions.first(where: { $0.account.token == token }) {
await sub.deleteSubscription() await sub.deleteSubscription()
} }
appAccountsManager.delete(account: appAccount) appAccountsManager.delete(account: appAccount)
Telemetry.signal("account.removed")
dismiss() dismiss()
} }
} }
} label: { } label: {
Label("account.action.logout", systemImage: "trash") Text("account.action.logout")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
.sheet(isPresented: $isEditingAccount, content: {
EditAccountView()
})
.sheet(isPresented: $isEditingFilters, content: {
FiltersListView()
})
.toolbar { .toolbar {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
HStack { HStack {
AvatarView(account.avatar, config: .embed) AvatarView(url: account.avatar, size: .embed)
Text(account.safeDisplayName) Text(account.safeDisplayName)
.font(.headline) .font(.headline)
} }
} }
} }
.task { .task {
cachedPostsCount = await timelineCache.cachedPostsCount(for: appAccountsManager.currentClient.id) cachedPostsCount = await TimelineCache.shared.cachedPostsCount(for: appAccountsManager.currentClient.id)
} }
.navigationTitle(account.safeDisplayName) .navigationTitle(account.safeDisplayName)
#if !os(visionOS) .scrollContentBackground(.hidden)
.scrollContentBackground(.hidden) .background(theme.secondaryBackgroundColor)
.background(theme.secondaryBackgroundColor)
#endif
} }
} }

View file

@ -1,5 +1,4 @@
import AppAccount import AppAccount
import AuthenticationServices
import Combine import Combine
import DesignSystem import DesignSystem
import Env import Env
@ -7,20 +6,18 @@ import Models
import Network import Network
import NukeUI import NukeUI
import SafariServices import SafariServices
import Shimmer
import SwiftUI import SwiftUI
@MainActor
struct AddAccountView: View { struct AddAccountView: View {
@Environment(\.webAuthenticationSession) private var webAuthenticationSession
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@Environment(\.openURL) private var openURL
@Environment(AppAccountsManager.self) private var appAccountsManager @EnvironmentObject private var appAccountsManager: AppAccountsManager
@Environment(CurrentAccount.self) private var currentAccount @EnvironmentObject private var currentAccount: CurrentAccount
@Environment(CurrentInstance.self) private var currentInstance @EnvironmentObject private var currentInstance: CurrentInstance
@Environment(PushNotificationsService.self) private var pushNotifications @EnvironmentObject private var pushNotifications: PushNotificationsService
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@State private var instanceName: String = "" @State private var instanceName: String = ""
@State private var instance: Instance? @State private var instance: Instance?
@ -28,9 +25,7 @@ struct AddAccountView: View {
@State private var signInClient: Client? @State private var signInClient: Client?
@State private var instances: [InstanceSocial] = [] @State private var instances: [InstanceSocial] = []
@State private var instanceFetchError: LocalizedStringKey? @State private var instanceFetchError: LocalizedStringKey?
@State private var instanceSocialClient = InstanceSocialClient() @State private var oauthURL: URL?
@State private var searchingTask = Task<Void, Never> {}
@State private var getInstanceDetailTask = Task<Void, Never> {}
private let instanceNamePublisher = PassthroughSubject<String, Never>() private let instanceNamePublisher = PassthroughSubject<String, Never>()
@ -48,25 +43,16 @@ struct AddAccountView: View {
@FocusState private var isInstanceURLFieldFocused: Bool @FocusState private var isInstanceURLFieldFocused: Bool
private func cleanServerStr(_ server: String) -> String {
server.replacingOccurrences(of: " ", with: "")
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
TextField("instance.url", text: $instanceName) TextField("instance.url", text: $instanceName)
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
.keyboardType(.URL) .keyboardType(.URL)
.textContentType(.URL) .textContentType(.URL)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.focused($isInstanceURLFieldFocused) .focused($isInstanceURLFieldFocused)
.onChange(of: instanceName) { _, _ in
instanceName = cleanServerStr(instanceName)
}
if let instanceFetchError { if let instanceFetchError {
Text(instanceFetchError) Text(instanceFetchError)
} }
@ -82,78 +68,78 @@ struct AddAccountView: View {
.formStyle(.grouped) .formStyle(.grouped)
.navigationTitle("account.add.navigation-title") .navigationTitle("account.add.navigation-title")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#if !os(visionOS) .scrollContentBackground(.hidden)
.scrollContentBackground(.hidden) .background(theme.secondaryBackgroundColor)
.background(theme.secondaryBackgroundColor) .scrollDismissesKeyboard(.immediately)
.scrollDismissesKeyboard(.immediately) .toolbar {
#endif if !appAccountsManager.availableAccounts.isEmpty {
.toolbar { ToolbarItem(placement: .navigationBarLeading) {
CancelToolbarItem() Button("action.cancel", action: { dismiss() })
}
.onAppear {
isInstanceURLFieldFocused = true
let instanceName = instanceName
Task {
let instances = await instanceSocialClient.fetchInstances(keyword: instanceName)
withAnimation {
self.instances = instances
}
} }
isSigninIn = false
} }
.onChange(of: instanceName) { }
searchingTask.cancel() .onAppear {
let instanceName = instanceName isInstanceURLFieldFocused = true
let instanceSocialClient = instanceSocialClient let client = InstanceSocialClient()
searchingTask = Task { Task {
try? await Task.sleep(for: .seconds(0.1)) let instances = await client.fetchInstances()
guard !Task.isCancelled else { return } withAnimation {
self.instances = instances
let instances = await instanceSocialClient.fetchInstances(keyword: instanceName)
withAnimation {
self.instances = instances
}
} }
}
getInstanceDetailTask.cancel() isSigninIn = false
getInstanceDetailTask = Task { }
try? await Task.sleep(for: .seconds(0.1)) .onChange(of: instanceName) { newValue in
guard !Task.isCancelled else { return } instanceNamePublisher.send(newValue)
}
do { .onReceive(instanceNamePublisher.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)) { _ in
// bare bones preflight for domain validity // let newValue = newValue
let instanceDetailClient = Client(server: sanitizedName) // .replacingOccurrences(of: "http://", with: "")
if // .replacingOccurrences(of: "https://", with: "")
instanceDetailClient.server.contains("."), let client = Client(server: sanitizedName)
instanceDetailClient.server.last != "." Task {
{ do {
let instance: Instance = try await instanceDetailClient.get(endpoint: Instances.instance) // bare bones preflight for domain validity
withAnimation { if client.server.contains(".") && client.server.last != "." {
self.instance = instance let instance: Instance = try await client.get(endpoint: Instances.instance)
self.instanceName = sanitizedName // clean up the text box, principally to chop off the username if present so it's clear that you might not wind up siging in as the thing in the box withAnimation {
} self.instance = instance
instanceFetchError = nil self.instanceName = sanitizedName // clean up the text box, principally to chop off the username if present so it's clear that you might not wind up siging in as the thing in the box
} else {
instance = nil
instanceFetchError = nil
} }
} catch _ as ServerError { instanceFetchError = nil
instance = nil } else {
instanceFetchError = "account.add.error.instance-not-supported"
} catch {
instance = nil instance = nil
instanceFetchError = nil instanceFetchError = nil
} }
} catch _ as DecodingError {
instance = nil
instanceFetchError = "account.add.error.instance-not-supported"
} catch {
instance = nil
} }
} }
.onChange(of: scenePhase) { _, newValue in }
switch newValue { .onChange(of: scenePhase, perform: { scenePhase in
case .active: switch scenePhase {
isSigninIn = false case .active:
default: isSigninIn = false
break default:
} break
} }
})
.onOpenURL(perform: { url in
Task {
await continueSignIn(url: url)
}
})
.onChange(of: oauthURL, perform: { newValue in
if newValue == nil {
isSigninIn = false
}
})
.sheet(item: $oauthURL, content: { url in
SafariView(url: url)
})
} }
} }
@ -182,9 +168,7 @@ struct AddAccountView: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
} }
#if !os(visionOS)
.listRowBackground(theme.tintColor) .listRowBackground(theme.tintColor)
#endif
} }
private var instancesListView: some View { private var instancesListView: some View {
@ -192,65 +176,30 @@ struct AddAccountView: View {
if instances.isEmpty { if instances.isEmpty {
placeholderRow placeholderRow
} else { } else {
ForEach(instances) { instance in ForEach(sanitizedName.isEmpty ? instances : instances.filter { $0.name.contains(sanitizedName.lowercased()) }) { instance in
Button { Button {
instanceName = instance.name self.instanceName = instance.name
} label: { } label: {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
LazyImage(url: instance.thumbnail) { state in Text(instance.name)
if let image = state.image { .font(.scaledHeadline)
image .foregroundColor(.primary)
.resizable() Text(instance.info?.shortDescription ?? "")
.scaledToFill() .font(.scaledBody)
} else { .foregroundColor(.gray)
Rectangle().fill(theme.tintColor.opacity(0.1)) (Text("instance.list.users-\(instance.users)")
} + Text("")
} + Text("instance.list.posts-\(instance.statuses)"))
.frame(height: 100) .font(.scaledFootnote)
.frame(maxWidth: .infinity) .foregroundColor(.gray)
.clipped()
VStack(alignment: .leading) {
HStack {
Text(instance.name)
.font(.scaledHeadline)
.foregroundColor(.primary)
Spacer()
(Text("instance.list.users-\(formatAsNumber(instance.users))")
+ Text("")
+ Text("instance.list.posts-\(formatAsNumber(instance.statuses))"))
.foregroundStyle(theme.tintColor)
}
.padding(.bottom, 5)
Text(instance.info?.shortDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "")
.foregroundStyle(Color.secondary)
.lineLimit(10)
}
.font(.scaledFootnote)
.padding(10)
} }
} }
#if !os(visionOS) .listRowBackground(theme.primaryBackgroundColor)
.background(theme.primaryBackgroundColor)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 10, leading: 0, bottom: 10, trailing: 0))
.listRowSeparator(.hidden)
.clipShape(RoundedRectangle(cornerRadius: 4))
#endif
} }
} }
} }
} }
private func formatAsNumber(_ string: String) -> String {
(Int(string) ?? 0)
.formatted(
.number
.notation(.compactName)
.locale(.current)
)
}
private var placeholderRow: some View { private var placeholderRow: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("placeholder.loading.short") Text("placeholder.loading.short")
@ -258,26 +207,25 @@ struct AddAccountView: View {
.foregroundColor(.primary) .foregroundColor(.primary)
Text("placeholder.loading.long") Text("placeholder.loading.long")
.font(.scaledBody) .font(.scaledBody)
.foregroundStyle(.secondary) .foregroundColor(.gray)
Text("placeholder.loading.short") Text("placeholder.loading.short")
.font(.scaledFootnote) .font(.scaledFootnote)
.foregroundStyle(.secondary) .foregroundColor(.gray)
} }
.redacted(reason: .placeholder) .redacted(reason: .placeholder)
.allowsHitTesting(false) .shimmering()
#if !os(visionOS) .listRowBackground(theme.primaryBackgroundColor)
.listRowBackground(theme.primaryBackgroundColor)
#endif
} }
private func signIn() async { private func signIn() async {
signInClient = .init(server: sanitizedName) do {
if let oauthURL = try? await signInClient?.oauthURL(), signInClient = .init(server: sanitizedName)
let url = try? await webAuthenticationSession.authenticate(using: oauthURL, if let oauthURL = try await signInClient?.oauthURL() {
callbackURLScheme: AppInfo.scheme.replacingOccurrences(of: "://", with: "")) self.oauthURL = oauthURL
{ } else {
await continueSignIn(url: url) isSigninIn = false
} else { }
} catch {
isSigninIn = false isSigninIn = false
} }
} }
@ -288,10 +236,10 @@ struct AddAccountView: View {
return return
} }
do { do {
oauthURL = nil
let oauthToken = try await client.continueOauthFlow(url: url) let oauthToken = try await client.continueOauthFlow(url: url)
let client = Client(server: client.server, oauthToken: oauthToken) let client = Client(server: client.server, oauthToken: oauthToken)
let account: Account = try await client.get(endpoint: Accounts.verifyCredentials) let account: Account = try await client.get(endpoint: Accounts.verifyCredentials)
Telemetry.signal("account.added")
appAccountsManager.add(account: AppAccount(server: client.server, appAccountsManager.add(account: AppAccount(server: client.server,
accountName: "\(account.acct)@\(client.server)", accountName: "\(account.acct)@\(client.server)",
oauthToken: oauthToken)) oauthToken: oauthToken))
@ -302,7 +250,18 @@ struct AddAccountView: View {
isSigninIn = false isSigninIn = false
dismiss() dismiss()
} catch { } catch {
oauthURL = nil
isSigninIn = false isSigninIn = false
} }
} }
} }
struct SafariView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context _: UIViewControllerRepresentableContext<SafariView>) -> SFSafariViewController {
SFSafariViewController(url: url)
}
func updateUIViewController(_: SFSafariViewController, context _: UIViewControllerRepresentableContext<SafariView>) {}
}

View file

@ -5,33 +5,28 @@ import Models
import Network import Network
import NukeUI import NukeUI
import SwiftUI import SwiftUI
import Timeline
import UserNotifications import UserNotifications
@MainActor
struct ContentSettingsView: View { struct ContentSettingsView: View {
@Environment(UserPreferences.self) private var userPreferences @EnvironmentObject private var userPreferences: UserPreferences
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@State private var contentFilter = TimelineContentFilter.shared
var body: some View { var body: some View {
@Bindable var userPreferences = userPreferences
Form { Form {
Section("settings.content.boosts") {
Toggle(isOn: $userPreferences.suppressDupeReblogs) {
Text("settings.content.hide-repeated-boosts")
}
}.listRowBackground(theme.primaryBackgroundColor)
Section("settings.content.media") { Section("settings.content.media") {
Toggle(isOn: $userPreferences.autoPlayVideo) { Toggle(isOn: $userPreferences.autoPlayVideo) {
Text("settings.other.autoplay-video") Text("settings.other.autoplay-video")
} }
Toggle(isOn: $userPreferences.muteVideo) {
Text("settings.other.mute-video")
}
Toggle(isOn: $userPreferences.showAltTextForMedia) { Toggle(isOn: $userPreferences.showAltTextForMedia) {
Text("settings.content.media.show.alt") Text("settings.content.media.show.alt")
} }
} }.listRowBackground(theme.primaryBackgroundColor)
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Section("settings.content.sharing") { Section("settings.content.sharing") {
Picker("settings.content.sharing.share-button-behavior", selection: $userPreferences.shareButtonBehavior) { Picker("settings.content.sharing.share-button-behavior", selection: $userPreferences.shareButtonBehavior) {
@ -41,25 +36,20 @@ struct ContentSettingsView: View {
} }
} }
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
Section("settings.content.instance-settings") { Section("settings.content.instance-settings") {
Toggle(isOn: $userPreferences.useInstanceContentSettings) { Toggle(isOn: $userPreferences.useInstanceContentSettings) {
Text("settings.content.use-instance-settings") Text("settings.content.use-instance-settings")
} }
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif .onChange(of: userPreferences.useInstanceContentSettings) { newVal in
.onChange(of: userPreferences.useInstanceContentSettings) { _, newVal in
if newVal { if newVal {
userPreferences.appAutoExpandSpoilers = userPreferences.autoExpandSpoilers userPreferences.appAutoExpandSpoilers = userPreferences.autoExpandSpoilers
userPreferences.appAutoExpandMedia = userPreferences.autoExpandMedia userPreferences.appAutoExpandMedia = userPreferences.autoExpandMedia
userPreferences.appDefaultPostsSensitive = userPreferences.postIsSensitive userPreferences.appDefaultPostsSensitive = userPreferences.postIsSensitive
userPreferences.appDefaultPostVisibility = userPreferences.postVisibility userPreferences.appDefaultPostVisibility = userPreferences.postVisibility
userPreferences.appRequireAltText = userPreferences.appRequireAltText
} }
} }
@ -84,9 +74,7 @@ struct ContentSettingsView: View {
} footer: { } footer: {
Text("settings.content.collapse-long-posts-hint") Text("settings.content.collapse-long-posts-hint")
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
Section("settings.content.posting") { Section("settings.content.posting") {
Picker("settings.content.default-visibility", selection: $userPreferences.appDefaultPostVisibility) { Picker("settings.content.default-visibility", selection: $userPreferences.appDefaultPostVisibility) {
@ -99,13 +87,12 @@ struct ContentSettingsView: View {
Picker("settings.content.default-reply-visibility", selection: $userPreferences.appDefaultReplyVisibility) { Picker("settings.content.default-reply-visibility", selection: $userPreferences.appDefaultReplyVisibility) {
ForEach(Visibility.allCases, id: \.rawValue) { vis in ForEach(Visibility.allCases, id: \.rawValue) { vis in
if UserPreferences.getIntOfVisibility(vis) <= if UserPreferences.getIntOfVisibility(vis) <=
UserPreferences.getIntOfVisibility(userPreferences.postVisibility) UserPreferences.getIntOfVisibility(userPreferences.postVisibility) {
{
Text(vis.title).tag(vis) Text(vis.title).tag(vis)
} }
} }
} }
.onChange(of: userPreferences.postVisibility) { .onChange(of: userPreferences.postVisibility) { newValue in
userPreferences.conformReplyVisibilityConstraints() userPreferences.conformReplyVisibilityConstraints()
} }
@ -113,37 +100,12 @@ struct ContentSettingsView: View {
Text("settings.content.default-sensitive") Text("settings.content.default-sensitive")
} }
.disabled(userPreferences.useInstanceContentSettings) .disabled(userPreferences.useInstanceContentSettings)
Toggle(isOn: $userPreferences.appRequireAltText) {
Text("settings.content.require-alt-text")
}
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Section("timeline.content-filter.title") {
Toggle(isOn: $contentFilter.showBoosts) {
Label("timeline.filter.show-boosts", image: "Rocket")
}
Toggle(isOn: $contentFilter.showReplies) {
Label("timeline.filter.show-replies", systemImage: "bubble.left.and.bubble.right")
}
Toggle(isOn: $contentFilter.showThreads) {
Label("timeline.filter.show-threads", systemImage: "bubble.left.and.text.bubble.right")
}
Toggle(isOn: $contentFilter.showQuotePosts) {
Label("timeline.filter.show-quote", systemImage: "quote.bubble")
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
.navigationTitle("settings.content.navigation-title") .navigationTitle("settings.content.navigation-title")
#if !os(visionOS) .scrollContentBackground(.hidden)
.scrollContentBackground(.hidden) .background(theme.secondaryBackgroundColor)
.background(theme.secondaryBackgroundColor)
#endif
} }
} }

View file

@ -3,29 +3,57 @@ import DesignSystem
import Env import Env
import Models import Models
import Network import Network
import Observation import Status
import StatusKit
import SwiftUI import SwiftUI
@MainActor class DisplaySettingsLocalValues: ObservableObject {
@Observable class DisplaySettingsLocalValues { @Published var tintColor = Theme.shared.tintColor
var tintColor = Theme.shared.tintColor @Published var primaryBackgroundColor = Theme.shared.primaryBackgroundColor
var primaryBackgroundColor = Theme.shared.primaryBackgroundColor @Published var secondaryBackgroundColor = Theme.shared.secondaryBackgroundColor
var secondaryBackgroundColor = Theme.shared.secondaryBackgroundColor @Published var labelColor = Theme.shared.labelColor
var labelColor = Theme.shared.labelColor @Published var lineSpacing = Theme.shared.lineSpacing
var lineSpacing = Theme.shared.lineSpacing @Published var fontSizeScale = Theme.shared.fontSizeScale
var fontSizeScale = Theme.shared.fontSizeScale
private let debouncesDelay: DispatchQueue.SchedulerTimeType.Stride = .seconds(0.5)
private var subscriptions = Set<AnyCancellable>()
init() {
$tintColor
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newColor in Theme.shared.tintColor = newColor })
.store(in: &subscriptions)
$primaryBackgroundColor
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newColor in Theme.shared.primaryBackgroundColor = newColor })
.store(in: &subscriptions)
$secondaryBackgroundColor
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newColor in Theme.shared.secondaryBackgroundColor = newColor })
.store(in: &subscriptions)
$labelColor
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newColor in Theme.shared.labelColor = newColor })
.store(in: &subscriptions)
$lineSpacing
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newSpacing in Theme.shared.lineSpacing = newSpacing })
.store(in: &subscriptions)
$fontSizeScale
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newScale in Theme.shared.fontSizeScale = newScale })
.store(in: &subscriptions)
}
} }
@MainActor
struct DisplaySettingsView: View { struct DisplaySettingsView: View {
typealias FontState = Theme.FontState typealias FontState = Theme.FontState
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@Environment(UserPreferences.self) private var userPreferences @EnvironmentObject private var userPreferences: UserPreferences
@State private var localValues = DisplaySettingsLocalValues() @StateObject private var localValues = DisplaySettingsLocalValues()
@State private var isFontSelectorPresented = false @State private var isFontSelectorPresented = false
@ -36,56 +64,26 @@ struct DisplaySettingsView: View {
var body: some View { var body: some View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
Form { Form {
#if !os(visionOS) StatusRowView(viewModel: { previewStatusViewModel })
StatusRowExternalView(viewModel: previewStatusViewModel) .allowsHitTesting(false)
.allowsHitTesting(false) .opacity(0)
.opacity(0) .hidden()
.hidden() themeSection
themeSection
#endif
fontSection fontSection
layoutSection layoutSection
platformsSection platformsSection
resetSection resetSection
} }
.navigationTitle("settings.display.navigation-title") .navigationTitle("settings.display.navigation-title")
#if !os(visionOS) .scrollContentBackground(.hidden)
.scrollContentBackground(.hidden) .background(theme.secondaryBackgroundColor)
.background(theme.secondaryBackgroundColor) examplePost
#endif
.task(id: localValues.tintColor) {
do { try await Task.sleep(for: .microseconds(500)) } catch {}
theme.tintColor = localValues.tintColor
}
.task(id: localValues.primaryBackgroundColor) {
do { try await Task.sleep(for: .microseconds(500)) } catch {}
theme.primaryBackgroundColor = localValues.primaryBackgroundColor
}
.task(id: localValues.secondaryBackgroundColor) {
do { try await Task.sleep(for: .microseconds(500)) } catch {}
theme.secondaryBackgroundColor = localValues.secondaryBackgroundColor
}
.task(id: localValues.labelColor) {
do { try await Task.sleep(for: .microseconds(500)) } catch {}
theme.labelColor = localValues.labelColor
}
.task(id: localValues.lineSpacing) {
do { try await Task.sleep(for: .microseconds(500)) } catch {}
theme.lineSpacing = localValues.lineSpacing
}
.task(id: localValues.fontSizeScale) {
do { try await Task.sleep(for: .microseconds(500)) } catch {}
theme.fontSizeScale = localValues.fontSizeScale
}
#if !os(visionOS)
examplePost
#endif
} }
} }
private var examplePost: some View { private var examplePost: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
StatusRowExternalView(viewModel: previewStatusViewModel) StatusRowView(viewModel: { previewStatusViewModel })
.allowsHitTesting(false) .allowsHitTesting(false)
.padding(.layoutPadding) .padding(.layoutPadding)
.background(theme.primaryBackgroundColor) .background(theme.primaryBackgroundColor)
@ -101,9 +99,7 @@ struct DisplaySettingsView: View {
} }
} }
@ViewBuilder
private var themeSection: some View { private var themeSection: some View {
@Bindable var theme = theme
Section { Section {
Toggle("settings.display.theme.systemColor", isOn: $theme.followSystemColorScheme) Toggle("settings.display.theme.systemColor", isOn: $theme.followSystemColorScheme)
themeSelectorButton themeSelectorButton
@ -115,7 +111,7 @@ struct DisplaySettingsView: View {
} }
.disabled(theme.followSystemColorScheme) .disabled(theme.followSystemColorScheme)
.opacity(theme.followSystemColorScheme ? 0.5 : 1.0) .opacity(theme.followSystemColorScheme ? 0.5 : 1.0)
.onChange(of: theme.selectedSet) { .onChange(of: theme.selectedSet) { _ in
localValues.tintColor = theme.tintColor localValues.tintColor = theme.tintColor
localValues.primaryBackgroundColor = theme.primaryBackgroundColor localValues.primaryBackgroundColor = theme.primaryBackgroundColor
localValues.secondaryBackgroundColor = theme.secondaryBackgroundColor localValues.secondaryBackgroundColor = theme.secondaryBackgroundColor
@ -128,9 +124,7 @@ struct DisplaySettingsView: View {
Text("settings.display.section.theme.footer") Text("settings.display.section.theme.footer")
} }
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
private var fontSection: some View { private var fontSection: some View {
@ -182,15 +176,10 @@ struct DisplaySettingsView: View {
d[.leading] d[.leading]
} }
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
@ViewBuilder
private var layoutSection: some View { private var layoutSection: some View {
@Bindable var theme = theme
@Bindable var userPreferences = userPreferences
Section("settings.display.section.display") { Section("settings.display.section.display") {
Picker("settings.display.avatar.position", selection: $theme.avatarPosition) { Picker("settings.display.avatar.position", selection: $theme.avatarPosition) {
ForEach(Theme.AvatarPosition.allCases, id: \.rawValue) { position in ForEach(Theme.AvatarPosition.allCases, id: \.rawValue) { position in
@ -208,67 +197,53 @@ struct DisplaySettingsView: View {
Text(buttonStyle.description).tag(buttonStyle) Text(buttonStyle.description).tag(buttonStyle)
} }
} }
Picker("settings.display.status.action-secondary", selection: $theme.statusActionSecondary) {
ForEach(Theme.StatusActionSecondary.allCases, id: \.rawValue) { action in
Text(action.description).tag(action)
}
}
Picker("settings.display.status.media-style", selection: $theme.statusDisplayStyle) { Picker("settings.display.status.media-style", selection: $theme.statusDisplayStyle) {
ForEach(Theme.StatusDisplayStyle.allCases, id: \.rawValue) { buttonStyle in ForEach(Theme.StatusDisplayStyle.allCases, id: \.rawValue) { buttonStyle in
Text(buttonStyle.description).tag(buttonStyle) Text(buttonStyle.description).tag(buttonStyle)
} }
} }
Toggle("settings.display.translate-button", isOn: $userPreferences.showTranslateButton) Toggle("settings.display.translate-button", isOn: $userPreferences.showTranslateButton)
Toggle("settings.display.pending-at-bottom", isOn: $userPreferences.pendingShownAtBottom)
Toggle("settings.display.pending-left", isOn: $userPreferences.pendingShownLeft)
Toggle("settings.display.show-reply-indentation", isOn: $userPreferences.showReplyIndentation)
if userPreferences.showReplyIndentation {
VStack {
Slider(value: .init(get: {
Double(userPreferences.maxReplyIndentation)
}, set: { newVal in
userPreferences.maxReplyIndentation = UInt(newVal)
}), in: 1 ... 20, step: 1)
Text("settings.display.max-reply-indentation-\(String(userPreferences.maxReplyIndentation))")
.font(.scaledBody)
}
.alignmentGuide(.listRowSeparatorLeading) { d in
d[.leading]
}
}
Toggle("settings.display.show-account-popover", isOn: $userPreferences.showAccountPopover)
Toggle("Show Content Gradient", isOn: $theme.showContentGradient)
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
@ViewBuilder @ViewBuilder
private var platformsSection: some View { private var platformsSection: some View {
@Bindable var userPreferences = userPreferences if UIDevice.current.userInterfaceIdiom == .phone {
Section("iPhone") {
Toggle("settings.display.show-tab-label", isOn: $userPreferences.showiPhoneTabLabel)
}
.listRowBackground(theme.primaryBackgroundColor)
}
if UIDevice.current.userInterfaceIdiom == .pad { if UIDevice.current.userInterfaceIdiom == .pad {
Section("settings.display.section.platform") { Section("iPad") {
Toggle("settings.display.show-ipad-column", isOn: $userPreferences.showiPadSecondaryColumn) Toggle("settings.display.show-ipad-column", isOn: $userPreferences.showiPadSecondaryColumn)
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
} }
private var resetSection: some View { private var resetSection: some View {
Section { Section {
Button { Button {
theme.restoreDefault() theme.followSystemColorScheme = true
theme.selectedSet = colorScheme == .dark ? .iceCubeDark : .iceCubeLight
theme.avatarShape = .rounded
theme.avatarPosition = .top
theme.statusActionsDisplay = .full
localValues.tintColor = theme.tintColor
localValues.primaryBackgroundColor = theme.primaryBackgroundColor
localValues.secondaryBackgroundColor = theme.secondaryBackgroundColor
localValues.labelColor = theme.labelColor
} label: { } label: {
Text("settings.display.restore") Text("settings.display.restore")
} }
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
private var themeSelectorButton: some View { private var themeSelectorButton: some View {

View file

@ -1,30 +1,24 @@
import DesignSystem import DesignSystem
import Env import Env
import Models import Models
import StatusKit import Status
import SwiftUI import SwiftUI
@MainActor
struct HapticSettingsView: View { struct HapticSettingsView: View {
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@Environment(UserPreferences.self) private var userPreferences @EnvironmentObject private var userPreferences: UserPreferences
var body: some View { var body: some View {
@Bindable var userPreferences = userPreferences
Form { Form {
Section { Section {
Toggle("settings.haptic.timeline", isOn: $userPreferences.hapticTimelineEnabled) Toggle("settings.haptic.timeline", isOn: $userPreferences.hapticTimelineEnabled)
Toggle("settings.haptic.tab-selection", isOn: $userPreferences.hapticTabSelectionEnabled) Toggle("settings.haptic.tab-selection", isOn: $userPreferences.hapticTabSelectionEnabled)
Toggle("settings.haptic.buttons", isOn: $userPreferences.hapticButtonPressEnabled) Toggle("settings.haptic.buttons", isOn: $userPreferences.hapticButtonPressEnabled)
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
.navigationTitle("settings.haptic.navigation-title") .navigationTitle("settings.haptic.navigation-title")
#if !os(visionOS) .scrollContentBackground(.hidden)
.scrollContentBackground(.hidden) .background(theme.secondaryBackgroundColor)
.background(theme.secondaryBackgroundColor)
#endif
} }
} }

View file

@ -1,7 +1,6 @@
import DesignSystem import DesignSystem
import SwiftUI import SwiftUI
@MainActor
struct IconSelectorView: View { struct IconSelectorView: View {
enum Icon: Int, CaseIterable, Identifiable { enum Icon: Int, CaseIterable, Identifiable {
var id: String { var id: String {
@ -9,7 +8,7 @@ struct IconSelectorView: View {
} }
init(string: String) { init(string: String) {
if string == "AppIcon" { if string == Icon.primary.appIconName {
self = .primary self = .primary
} else { } else {
self = .init(rawValue: Int(String(string.replacing("AppIconAlternate", with: "")))!)! self = .init(rawValue: Int(String(string.replacing("AppIconAlternate", with: "")))!)!
@ -17,24 +16,30 @@ struct IconSelectorView: View {
} }
case primary = 0 case primary = 0
case alt1, alt2, alt3, alt4, alt5, alt6, alt7, alt8, alt9, alt10, alt11, alt12, alt13, alt14, alt15 case alt1, alt2, alt3, alt4, alt5, alt6, alt7, alt8
case alt16, alt17, alt18, alt19, alt20, alt21 case alt9, alt10, alt11, alt12, alt13, alt14
case alt22, alt23, alt24, alt25, alt26 case alt15, alt16, alt17, alt18, alt19, alt20, alt21
case alt27, alt28, alt29 case alt22, alt23, alt24, alt25
case alt30, alt31, alt32, alt33, alt34, alt35, alt36 case alt26, alt27, alt28
case alt29, alt30, alt31, alt32
case alt33
case alt34, alt35
case alt36
case alt37 case alt37
case alt38 case alt38, alt39
case alt39, alt40, alt41, alt42, alt43 case alt40
case alt44, alt45
case alt46, alt47, alt48
case alt49
var appIconName: String { var appIconName: String {
return "AppIconAlternate\(rawValue)" switch self {
case .primary:
return "AppIcon"
default:
return "AppIconAlternate\(rawValue)"
}
} }
var previewImageName: String { var iconName: String {
return "AppIconAlternate\(rawValue)-image" "icon\(rawValue)"
} }
} }
@ -44,24 +49,20 @@ struct IconSelectorView: View {
let icons: [Icon] let icons: [Icon]
static let items = [ static let items = [
IconSelector(title: "settings.app.icon.official".localized, icons: [ IconSelector(title: "settings.app.icon.official".localized, icons: [.primary, .alt1, .alt2, .alt3, .alt4, .alt5, .alt6, .alt7, .alt8,
.primary, .alt46, .alt1, .alt2, .alt3, .alt4, .alt9, .alt10, .alt11, .alt12, .alt13, .alt14,
.alt5, .alt6, .alt7, .alt8, .alt15, .alt16, .alt17, .alt18, .alt19, .alt25]),
.alt9, .alt10, .alt11, .alt12, .alt13, .alt14, .alt15, IconSelector(title: "\("settings.app.icon.designed-by".localized) Albert Kinng", icons: [.alt20, .alt21, .alt22, .alt23, .alt24]),
.alt16, .alt17, .alt18, .alt19, .alt20, .alt21]), IconSelector(title: "\("settings.app.icon.designed-by".localized) Dan van Moll", icons: [.alt26, .alt27, .alt28]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Albert Kinng", icons: [.alt22, .alt23, .alt24, .alt25, .alt26]), IconSelector(title: "\("settings.app.icon.designed-by".localized) Chanhwi Joo (GitHub @te6-in)", icons: [.alt29, .alt34, .alt31, .alt35, .alt30, .alt32, .alt40]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Dan van Moll", icons: [.alt27, .alt28, .alt29]), IconSelector(title: "\("settings.app.icon.designed-by".localized) W. Kovács Ágnes (@wildgica)", icons: [.alt33]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Chanhwi Joo (GitHub @te6-in)", icons: [.alt30, .alt31, .alt32, .alt33, .alt34, .alt35, .alt36]), IconSelector(title: "\("settings.app.icon.designed-by".localized) Duncan Horne", icons: [.alt36]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) W. Kovács Ágnes (@wildgica)", icons: [.alt37]), IconSelector(title: "\("settings.app.icon.designed-by".localized) BeAware@social.beaware.live", icons: [.alt37]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Duncan Horne", icons: [.alt38]), IconSelector(title: "\("settings.app.icon.designed-by".localized) Simone Margio", icons: [.alt38, .alt39]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) BeAware@social.beaware.live", icons: [.alt39, .alt40, .alt41, .alt42, .alt43]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Simone Margio", icons: [.alt44, .alt45]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Peter Broqvist (@PKB)", icons: [.alt47, .alt48]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Oz Tsori (@oztsori)", icons: [.alt49]),
] ]
} }
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@State private var currentIcon = UIApplication.shared.alternateIconName ?? Icon.primary.appIconName @State private var currentIcon = UIApplication.shared.alternateIconName ?? Icon.primary.appIconName
private let columns = [GridItem(.adaptive(minimum: 125, maximum: 1024))] private let columns = [GridItem(.adaptive(minimum: 125, maximum: 1024))]
@ -81,9 +82,7 @@ struct IconSelectorView: View {
.padding(6) .padding(6)
.navigationTitle("settings.app.icon.navigation-title") .navigationTitle("settings.app.icon.navigation-title")
} }
#if !os(visionOS)
.background(theme.primaryBackgroundColor) .background(theme.primaryBackgroundColor)
#endif
} }
private func makeIconGridView(icons: [Icon]) -> some View { private func makeIconGridView(icons: [Icon]) -> some View {
@ -101,7 +100,7 @@ struct IconSelectorView: View {
} }
} label: { } label: {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
Image(icon.previewImageName) Image(uiImage: .init(named: icon.iconName) ?? .init())
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(minHeight: 125, maxHeight: 1024) .frame(minHeight: 125, maxHeight: 1024)
@ -114,7 +113,6 @@ struct IconSelectorView: View {
} }
} }
} }
.buttonStyle(.plain)
} }
} }
} }
@ -122,6 +120,6 @@ struct IconSelectorView: View {
extension String { extension String {
var localized: String { var localized: String {
NSLocalizedString(self, comment: "") return NSLocalizedString(self, comment: "")
} }
} }

View file

@ -4,7 +4,7 @@ import NukeUI
import SwiftUI import SwiftUI
struct InstanceInfoView: View { struct InstanceInfoView: View {
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
let instance: Instance let instance: Instance
@ -13,15 +13,13 @@ struct InstanceInfoView: View {
InstanceInfoSection(instance: instance) InstanceInfoSection(instance: instance)
} }
.navigationTitle("instance.info.navigation-title") .navigationTitle("instance.info.navigation-title")
#if !os(visionOS) .scrollContentBackground(.hidden)
.scrollContentBackground(.hidden) .background(theme.secondaryBackgroundColor)
.background(theme.secondaryBackgroundColor)
#endif
} }
} }
public struct InstanceInfoSection: View { public struct InstanceInfoSection: View {
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
let instance: Instance let instance: Instance
@ -37,9 +35,7 @@ public struct InstanceInfoSection: View {
LabeledContent("instance.info.posts", value: format(instance.stats.statusCount)) LabeledContent("instance.info.posts", value: format(instance.stats.statusCount))
LabeledContent("instance.info.domains", value: format(instance.stats.domainCount)) LabeledContent("instance.info.domains", value: format(instance.stats.domainCount))
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
if let rules = instance.rules { if let rules = instance.rules {
Section("instance.info.section.rules") { Section("instance.info.section.rules") {
@ -47,9 +43,7 @@ public struct InstanceInfoSection: View {
Text(rule.text.trimmingCharacters(in: .whitespacesAndNewlines)) Text(rule.text.trimmingCharacters(in: .whitespacesAndNewlines))
} }
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
} }

View file

@ -7,13 +7,12 @@ import NukeUI
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
@MainActor
struct PushNotificationsView: View { struct PushNotificationsView: View {
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@Environment(AppAccountsManager.self) private var appAccountsManager @EnvironmentObject private var appAccountsManager: AppAccountsManager
@Environment(PushNotificationsService.self) private var pushNotifications @EnvironmentObject private var pushNotifications: PushNotificationsService
@State public var subscription: PushNotificationSubscriptionSettings @StateObject public var subscription: PushNotificationSubscriptionSettings
var body: some View { var body: some View {
Form { Form {
@ -33,9 +32,7 @@ struct PushNotificationsView: View {
} footer: { } footer: {
Text("settings.push.main-toggle.description") Text("settings.push.main-toggle.description")
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
if subscription.isEnabled { if subscription.isEnabled {
Section { Section {
@ -88,9 +85,7 @@ struct PushNotificationsView: View {
Label("settings.push.new-posts", systemImage: "bubble.right") Label("settings.push.new-posts", systemImage: "bubble.right")
} }
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
Section { Section {
@ -105,18 +100,14 @@ struct PushNotificationsView: View {
} footer: { } footer: {
Text("settings.push.duplicate.footer") Text("settings.push.duplicate.footer")
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
.navigationTitle("settings.push.navigation-title") .navigationTitle("settings.push.navigation-title")
#if !os(visionOS) .scrollContentBackground(.hidden)
.scrollContentBackground(.hidden) .background(theme.secondaryBackgroundColor)
.background(theme.secondaryBackgroundColor) .task {
#endif await subscription.fetchSubscription()
.task { }
await subscription.fetchSubscription()
}
} }
private func updateSubscription() { private func updateSubscription() {

View file

@ -1,44 +0,0 @@
import DesignSystem
import Env
import Models
import SwiftData
import SwiftUI
struct RecenTagsSettingView: View {
@Environment(\.modelContext) private var context
@Environment(RouterPath.self) private var routerPath
@Environment(Theme.self) private var theme
@Query(sort: \RecentTag.lastUse, order: .reverse) var tags: [RecentTag]
var body: some View {
Form {
ForEach(tags) { tag in
VStack(alignment: .leading) {
Text("#\(tag.title)")
.font(.scaledBody)
.foregroundColor(.primary)
Text(tag.formattedDate)
.font(.scaledFootnote)
.foregroundStyle(.secondary)
}
}.onDelete { indexes in
if let index = indexes.first {
context.delete(tags[index])
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.navigationTitle("settings.general.recent-tags")
.scrollContentBackground(.hidden)
#if !os(visionOS)
.background(theme.secondaryBackgroundColor)
#endif
.toolbar {
EditButton()
}
}
}

View file

@ -1,45 +0,0 @@
import DesignSystem
import Env
import Models
import SwiftData
import SwiftUI
struct RemoteTimelinesSettingView: View {
@Environment(\.modelContext) private var context
@Environment(RouterPath.self) private var routerPath
@Environment(Theme.self) private var theme
@Query(sort: \LocalTimeline.creationDate, order: .reverse) var localTimelines: [LocalTimeline]
var body: some View {
Form {
ForEach(localTimelines) { timeline in
Text(timeline.instance)
}.onDelete { indexes in
if let index = indexes.first {
context.delete(localTimelines[index])
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Button {
routerPath.presentedSheet = .addRemoteLocalTimeline
} label: {
Label("settings.timeline.add", systemImage: "badge.plus.radiowaves.right")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.navigationTitle("settings.general.remote-timelines")
.scrollContentBackground(.hidden)
#if !os(visionOS)
.background(theme.secondaryBackgroundColor)
#endif
.toolbar {
EditButton()
}
}
}

View file

@ -6,31 +6,26 @@ import Foundation
import Models import Models
import Network import Network
import Nuke import Nuke
import SwiftData
import SwiftUI import SwiftUI
import Timeline import Timeline
@MainActor
struct SettingsTabs: View { struct SettingsTabs: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(PushNotificationsService.self) private var pushNotifications @EnvironmentObject private var pushNotifications: PushNotificationsService
@Environment(UserPreferences.self) private var preferences @EnvironmentObject private var preferences: UserPreferences
@Environment(Client.self) private var client @EnvironmentObject private var client: Client
@Environment(CurrentInstance.self) private var currentInstance @EnvironmentObject private var currentInstance: CurrentInstance
@Environment(AppAccountsManager.self) private var appAccountsManager @EnvironmentObject private var appAccountsManager: AppAccountsManager
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@StateObject private var routerPath = RouterPath()
@State private var routerPath = RouterPath()
@State private var addAccountSheetPresented = false @State private var addAccountSheetPresented = false
@State private var isEditingAccount = false @State private var isEditingAccount = false
@State private var cachedRemoved = false @State private var cachedRemoved = false
@State private var timelineCache = TimelineCache()
let isModal: Bool @Binding var popToRootTab: Tab
@State private var startingPoint: SettingsStartingPoint? = nil
var body: some View { var body: some View {
NavigationStack(path: $routerPath.path) { NavigationStack(path: $routerPath.path) {
@ -39,59 +34,29 @@ struct SettingsTabs: View {
accountsSection accountsSection
generalSection generalSection
otherSections otherSections
postStreamingSection
AISection
cacheSection cacheSection
} }
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
#if !os(visionOS) .background(theme.secondaryBackgroundColor)
.background(theme.secondaryBackgroundColor) .navigationTitle(Text("settings.title"))
#endif .navigationBarTitleDisplayMode(.inline)
.navigationTitle(Text("settings.title")) .toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
.navigationBarTitleDisplayMode(.inline) .toolbar {
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar) if UIDevice.current.userInterfaceIdiom == .phone {
.toolbar { ToolbarItem {
if isModal { Button {
ToolbarItem { dismiss()
Button { } label: {
dismiss() Text("action.done").bold()
} label: {
Text("action.done").bold()
}
} }
} }
if UIDevice.current.userInterfaceIdiom == .pad, !preferences.showiPadSecondaryColumn, !isModal {
SecondaryColumnToolbarItem()
}
} }
.withAppRouter() if UIDevice.current.userInterfaceIdiom == .pad && !preferences.showiPadSecondaryColumn {
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet) SecondaryColumnToolbarItem()
.onAppear {
startingPoint = RouterPath.settingsStartingPoint
RouterPath.settingsStartingPoint = nil
}
.navigationDestination(item: $startingPoint) { targetView in
switch targetView {
case .display:
DisplaySettingsView()
case .haptic:
HapticSettingsView()
case .remoteTimelines:
RemoteTimelinesSettingView()
case .tagGroups:
TagsGroupSettingView()
case .recentTags:
RecenTagsSettingView()
case .content:
ContentSettingsView()
case .swipeActions:
SwipeActionsSettingsView()
case .tabAndSidebarEntries:
EmptyView()
case .translation:
TranslationSettingsView()
}
} }
}
.withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
} }
.onAppear { .onAppear {
routerPath.client = client routerPath.client = client
@ -102,7 +67,12 @@ struct SettingsTabs: View {
} }
} }
.withSafariRouter() .withSafariRouter()
.environment(routerPath) .environmentObject(routerPath)
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
if popToRootTab == .notifications {
routerPath.path = []
}
}
} }
private var accountsSection: some View { private var accountsSection: some View {
@ -120,7 +90,7 @@ struct SettingsTabs: View {
.tint(.red) .tint(.red)
} }
} }
AppAccountView(viewModel: .init(appAccount: account), isParentPresented: .constant(false)) AppAccountView(viewModel: .init(appAccount: account))
} }
} }
.onDelete { indexSet in .onDelete { indexSet in
@ -131,14 +101,12 @@ struct SettingsTabs: View {
} }
} }
} }
addAccountButton
if !appAccountsManager.availableAccounts.isEmpty { if !appAccountsManager.availableAccounts.isEmpty {
editAccountButton editAccountButton
} }
addAccountButton
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
private func logoutAccount(account: AppAccount) async { private func logoutAccount(account: AppAccount) async {
@ -146,10 +114,9 @@ struct SettingsTabs: View {
let sub = pushNotifications.subscriptions.first(where: { $0.account.token == token }) let sub = pushNotifications.subscriptions.first(where: { $0.account.token == token })
{ {
let client = Client(server: account.server, oauthToken: token) let client = Client(server: account.server, oauthToken: token)
await timelineCache.clearCache(for: client.id) await TimelineCache.shared.clearCache(for: client.id)
await sub.deleteSubscription() await sub.deleteSubscription()
appAccountsManager.delete(account: account) appAccountsManager.delete(account: account)
Telemetry.signal("account.removed")
} }
} }
@ -169,50 +136,32 @@ struct SettingsTabs: View {
Label("settings.general.haptic", systemImage: "waveform.path") Label("settings.general.haptic", systemImage: "waveform.path")
} }
} }
NavigationLink(destination: RemoteTimelinesSettingView()) { NavigationLink(destination: remoteLocalTimelinesView) {
Label("settings.general.remote-timelines", systemImage: "dot.radiowaves.right") Label("settings.general.remote-timelines", systemImage: "dot.radiowaves.right")
} }
NavigationLink(destination: TagsGroupSettingView()) { NavigationLink(destination: tagGroupsView) {
Label("timeline.filter.tag-groups", systemImage: "number") Label("timeline.filter.tag-groups", systemImage: "number")
} }
NavigationLink(destination: RecenTagsSettingView()) {
Label("settings.general.recent-tags", systemImage: "clock")
}
NavigationLink(destination: ContentSettingsView()) { NavigationLink(destination: ContentSettingsView()) {
Label("settings.general.content", systemImage: "rectangle.stack") Label("settings.general.content", systemImage: "rectangle.stack")
} }
NavigationLink(destination: SwipeActionsSettingsView()) { NavigationLink(destination: SwipeActionsSettingsView()) {
Label("settings.general.swipeactions", systemImage: "hand.draw") Label("settings.general.swipeactions", systemImage: "hand.draw")
} }
if UIDevice.current.userInterfaceIdiom == .phone || horizontalSizeClass == .compact {
NavigationLink(destination: TabbarEntriesSettingsView()) {
Label("settings.general.tabbarEntries", systemImage: "platter.filled.bottom.iphone")
}
} else if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
NavigationLink(destination: SidebarEntriesSettingsView()) {
Label("settings.general.sidebarEntries", systemImage: "sidebar.squares.leading")
}
}
NavigationLink(destination: TranslationSettingsView()) { NavigationLink(destination: TranslationSettingsView()) {
Label("settings.general.translate", systemImage: "captions.bubble") Label("settings.general.translate", systemImage: "captions.bubble")
} }
#if !targetEnvironment(macCatalyst) Link(destination: URL(string: UIApplication.openSettingsURLString)!) {
Link(destination: URL(string: UIApplication.openSettingsURLString)!) { Label("settings.system", systemImage: "gear")
Label("settings.system", systemImage: "gear") }
} .tint(theme.labelColor)
.tint(theme.labelColor)
#endif
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
@ViewBuilder
private var otherSections: some View { private var otherSections: some View {
@Bindable var preferences = preferences Section("settings.section.other") {
Section { if !ProcessInfo.processInfo.isiOSAppOnMac {
#if !targetEnvironment(macCatalyst)
Picker(selection: $preferences.preferredBrowser) { Picker(selection: $preferences.preferredBrowser) {
ForEach(PreferredBrowser.allCases, id: \.rawValue) { browser in ForEach(PreferredBrowser.allCases, id: \.rawValue) { browser in
switch browser { switch browser {
@ -229,79 +178,35 @@ struct SettingsTabs: View {
Label("settings.general.browser.in-app.readerview", systemImage: "doc.plaintext") Label("settings.general.browser.in-app.readerview", systemImage: "doc.plaintext")
} }
.disabled(preferences.preferredBrowser != PreferredBrowser.inAppSafari) .disabled(preferences.preferredBrowser != PreferredBrowser.inAppSafari)
#endif }
Toggle(isOn: $preferences.isOpenAIEnabled) {
Label("settings.other.hide-openai", systemImage: "faxmachine")
}
Toggle(isOn: $preferences.isSocialKeyboardEnabled) { Toggle(isOn: $preferences.isSocialKeyboardEnabled) {
Label("settings.other.social-keyboard", systemImage: "keyboard") Label("settings.other.social-keyboard", systemImage: "keyboard")
} }
Toggle(isOn: $preferences.soundEffectEnabled) { Toggle(isOn: $preferences.soundEffectEnabled) {
Label("settings.other.sound-effect", systemImage: "hifispeaker") Label("settings.other.sound-effect", systemImage: "hifispeaker")
} }
Toggle(isOn: $preferences.fastRefreshEnabled) {
Label("settings.other.fast-refresh", systemImage: "arrow.clockwise")
}
} header: {
Text("settings.section.other")
} footer: {
Text("settings.section.other.footer")
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
}
@ViewBuilder
private var postStreamingSection: some View {
@Bindable var preferences = preferences
Section {
Toggle(isOn: $preferences.isPostsStreamingEnabled) {
Label("Posts streaming", systemImage: "clock.badge")
}
} header: {
Text("Streaming")
} footer: {
Text("Enabling post streaming will automatically add new posts at the top of your home timeline. Disable if you get performance issues.")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
@ViewBuilder
private var AISection: some View {
@Bindable var preferences = preferences
Section {
Toggle(isOn: $preferences.isOpenAIEnabled) {
Label("settings.other.hide-openai", systemImage: "faxmachine")
}
} header: {
Text("AI")
} footer: {
Text("Disable to hide AI assisted tool options such as copywritting and alt-image description generated using AI. Uses OpenAI API. See our Privacy Policy for more information.")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
} }
private var appSection: some View { private var appSection: some View {
Section { Section {
#if !targetEnvironment(macCatalyst) && !os(visionOS) if !ProcessInfo.processInfo.isiOSAppOnMac {
NavigationLink(destination: IconSelectorView()) { NavigationLink(destination: IconSelectorView()) {
Label { Label {
Text("settings.app.icon") Text("settings.app.icon")
} icon: { } icon: {
let icon = IconSelectorView.Icon(string: UIApplication.shared.alternateIconName ?? "AppIcon") let icon = IconSelectorView.Icon(string: UIApplication.shared.alternateIconName ?? "AppIcon")
if let image: UIImage = .init(named: icon.previewImageName) { Image(uiImage: .init(named: icon.iconName)!)
Image(uiImage: image) .resizable()
.resizable() .frame(width: 25, height: 25)
.frame(width: 25, height: 25) .cornerRadius(4)
.cornerRadius(4)
} else {
EmptyView()
}
} }
} }
#endif }
Link(destination: URL(string: "https://github.com/Dimillian/IceCubesApp")!) { Link(destination: URL(string: "https://github.com/Dimillian/IceCubesApp")!) {
Label("settings.app.source", systemImage: "link") Label("settings.app.source", systemImage: "link")
@ -321,18 +226,10 @@ struct SettingsTabs: View {
.tint(theme.labelColor) .tint(theme.labelColor)
} }
NavigationLink { NavigationLink(destination: AboutView()) {
AboutView()
} label: {
Label("settings.app.about", systemImage: "info.circle") Label("settings.app.about", systemImage: "info.circle")
} }
NavigationLink {
WishlistView()
} label: {
Label("Feature Requests", systemImage: "list.bullet.rectangle.portrait")
}
} header: { } header: {
Text("settings.section.app") Text("settings.section.app")
} footer: { } footer: {
@ -340,16 +237,14 @@ struct SettingsTabs: View {
Text("settings.section.app.footer \(appVersion)").frame(maxWidth: .infinity, alignment: .center) Text("settings.section.app.footer \(appVersion)").frame(maxWidth: .infinity, alignment: .center)
} }
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
private var addAccountButton: some View { private var addAccountButton: some View {
Button { Button {
addAccountSheetPresented.toggle() addAccountSheetPresented.toggle()
} label: { } label: {
Label("settings.account.add", systemImage: "person.badge.plus") Text("settings.account.add")
} }
.sheet(isPresented: $addAccountSheetPresented) { .sheet(isPresented: $addAccountSheetPresented) {
AddAccountView() AddAccountView()
@ -357,23 +252,84 @@ struct SettingsTabs: View {
} }
private var editAccountButton: some View { private var editAccountButton: some View {
Button(role: .destructive) { Button(role: isEditingAccount ? .none : .destructive) {
withAnimation { withAnimation {
isEditingAccount.toggle() isEditingAccount.toggle()
} }
} label: { } label: {
if isEditingAccount { if isEditingAccount {
Label("action.done", systemImage: "person.badge.minus") Text("action.done")
.foregroundStyle(.red)
} else { } else {
Label("account.action.logout", systemImage: "person.badge.minus") Text("account.action.logout")
.foregroundStyle(.red)
} }
} }
} }
private var tagGroupsView: some View {
Form {
ForEach(preferences.tagGroups, id: \.self) { group in
Label(group.title, systemImage: group.sfSymbolName)
.onTapGesture {
routerPath.presentedSheet = .editTagGroup(tagGroup: group, onSaved: nil)
}
}
.onDelete { indexes in
if let index = indexes.first {
_ = preferences.tagGroups.remove(at: index)
}
}
.onMove { source, destination in
preferences.tagGroups.move(fromOffsets: source, toOffset: destination)
}
.listRowBackground(theme.primaryBackgroundColor)
Button {
routerPath.presentedSheet = .addTagGroup
} label: {
Label("timeline.filter.add-tag-groups", systemImage: "plus")
}
.listRowBackground(theme.primaryBackgroundColor)
}
.navigationTitle("timeline.filter.tag-groups")
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.toolbar {
EditButton()
}
}
private var remoteLocalTimelinesView: some View {
Form {
ForEach(preferences.remoteLocalTimelines, id: \.self) { server in
Text(server)
}.onDelete { indexes in
if let index = indexes.first {
_ = preferences.remoteLocalTimelines.remove(at: index)
}
}
.onMove(perform: moveTimelineItems)
.listRowBackground(theme.primaryBackgroundColor)
Button {
routerPath.presentedSheet = .addRemoteLocalTimeline
} label: {
Label("settings.timeline.add", systemImage: "badge.plus.radiowaves.right")
}
.listRowBackground(theme.primaryBackgroundColor)
}
.navigationTitle("settings.general.remote-timelines")
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.toolbar {
EditButton()
}
}
private func moveTimelineItems(from source: IndexSet, to destination: Int) {
preferences.remoteLocalTimelines.move(fromOffsets: source, toOffset: destination)
}
private var cacheSection: some View { private var cacheSection: some View {
Section { Section("settings.section.cache") {
if cachedRemoved { if cachedRemoved {
Text("action.done") Text("action.done")
.transition(.move(edge: .leading)) .transition(.move(edge: .leading))
@ -385,13 +341,7 @@ struct SettingsTabs: View {
} }
} }
} }
} header: {
Text("settings.section.cache")
} footer: {
Text("Remove all cached images and videos")
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
} }

View file

@ -1,40 +0,0 @@
import DesignSystem
import Env
import SwiftUI
@MainActor
struct SidebarEntriesSettingsView: View {
@Environment(Theme.self) private var theme
@Environment(UserPreferences.self) private var userPreferences
@State private var sidebarTabs = SidebarTabs.shared
var body: some View {
@Bindable var userPreferences = userPreferences
Form {
Section {
ForEach($sidebarTabs.tabs, id: \.tab) { $tab in
if tab.tab != .profile && tab.tab != .settings {
Toggle(isOn: $tab.enabled) {
tab.tab.label
}
}
}
.onMove(perform: move)
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.environment(\.editMode, .constant(.active))
.navigationTitle("settings.general.sidebarEntries")
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
#endif
}
func move(from source: IndexSet, to destination: Int) {
sidebarTabs.tabs.move(fromOffsets: source, toOffset: destination)
}
}

View file

@ -1,9 +1,9 @@
import DesignSystem import DesignSystem
import Env import Env
import RevenueCat import RevenueCat
import Shimmer
import SwiftUI import SwiftUI
@MainActor
struct SupportAppView: View { struct SupportAppView: View {
enum Tip: String, CaseIterable { enum Tip: String, CaseIterable {
case one, two, three, four, supporter case one, two, three, four, supporter
@ -19,35 +19,35 @@ struct SupportAppView: View {
var title: LocalizedStringKey { var title: LocalizedStringKey {
switch self { switch self {
case .one: case .one:
"settings.support.one.title" return "settings.support.one.title"
case .two: case .two:
"settings.support.two.title" return "settings.support.two.title"
case .three: case .three:
"settings.support.three.title" return "settings.support.three.title"
case .four: case .four:
"settings.support.four.title" return "settings.support.four.title"
case .supporter: case .supporter:
"settings.support.supporter.title" return "settings.support.supporter.title"
} }
} }
var subtitle: LocalizedStringKey { var subtitle: LocalizedStringKey {
switch self { switch self {
case .one: case .one:
"settings.support.one.subtitle" return "settings.support.one.subtitle"
case .two: case .two:
"settings.support.two.subtitle" return "settings.support.two.subtitle"
case .three: case .three:
"settings.support.three.subtitle" return "settings.support.three.subtitle"
case .four: case .four:
"settings.support.four.subtitle" return "settings.support.four.subtitle"
case .supporter: case .supporter:
"settings.support.supporter.subtitle" return "settings.support.supporter.subtitle"
} }
} }
} }
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@ -68,25 +68,23 @@ struct SupportAppView: View {
linksSection linksSection
} }
.navigationTitle("settings.support.navigation-title") .navigationTitle("settings.support.navigation-title")
#if !os(visionOS) .scrollContentBackground(.hidden)
.scrollContentBackground(.hidden) .background(theme.secondaryBackgroundColor)
.background(theme.secondaryBackgroundColor) .alert("settings.support.alert.title", isPresented: $purchaseSuccessDisplayed, actions: {
#endif Button { purchaseSuccessDisplayed = false } label: { Text("alert.button.ok") }
.alert("settings.support.alert.title", isPresented: $purchaseSuccessDisplayed, actions: { }, message: {
Button { purchaseSuccessDisplayed = false } label: { Text("alert.button.ok") } Text("settings.support.alert.message")
}, message: { })
Text("settings.support.alert.message") .alert("alert.error", isPresented: $purchaseErrorDisplayed, actions: {
}) Button { purchaseErrorDisplayed = false } label: { Text("alert.button.ok") }
.alert("alert.error", isPresented: $purchaseErrorDisplayed, actions: { }, message: {
Button { purchaseErrorDisplayed = false } label: { Text("alert.button.ok") } Text("settings.support.alert.error.message")
}, message: { })
Text("settings.support.alert.error.message") .onAppear {
}) loadingProducts = true
.onAppear { fetchStoreProducts()
loadingProducts = true refreshUserInfo()
fetchStoreProducts() }
refreshUserInfo()
}
} }
private func purchase(product: StoreProduct) async { private func purchase(product: StoreProduct) async {
@ -105,8 +103,8 @@ struct SupportAppView: View {
} }
private func fetchStoreProducts() { private func fetchStoreProducts() {
Purchases.shared.getProducts(Tip.allCases.map(\.productId)) { products in Purchases.shared.getProducts(Tip.allCases.map { $0.productId }) { products in
subscription = products.first(where: { $0.productIdentifier == Tip.supporter.productId }) self.subscription = products.first(where: { $0.productIdentifier == Tip.supporter.productId })
self.products = products.filter { $0.productIdentifier != Tip.supporter.productId }.sorted(by: { $0.price < $1.price }) self.products = products.filter { $0.productIdentifier != Tip.supporter.productId }.sorted(by: { $0.price < $1.price })
withAnimation { withAnimation {
loadingProducts = false loadingProducts = false
@ -116,7 +114,7 @@ struct SupportAppView: View {
private func refreshUserInfo() { private func refreshUserInfo() {
Purchases.shared.getCustomerInfo { info, _ in Purchases.shared.getCustomerInfo { info, _ in
customerInfo = info self.customerInfo = info
} }
} }
@ -152,9 +150,7 @@ struct SupportAppView: View {
Text("settings.support.message-from-dev") Text("settings.support.message-from-dev")
} }
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
private var subscriptionSection: some View { private var subscriptionSection: some View {
@ -178,7 +174,7 @@ struct SupportAppView: View {
.font(.scaledSubheadline) .font(.scaledSubheadline)
Text(Tip.supporter.subtitle) Text(Tip.supporter.subtitle)
.font(.scaledFootnote) .font(.scaledFootnote)
.foregroundStyle(.secondary) .foregroundColor(.gray)
} }
Spacer() Spacer()
makePurchaseButton(product: subscription) makePurchaseButton(product: subscription)
@ -191,9 +187,7 @@ struct SupportAppView: View {
Text("settings.support.supporter.subscription-info") Text("settings.support.supporter.subscription-info")
} }
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
private var tipsSection: some View { private var tipsSection: some View {
@ -209,7 +203,7 @@ struct SupportAppView: View {
.font(.scaledSubheadline) .font(.scaledSubheadline)
Text(tip.subtitle) Text(tip.subtitle)
.font(.scaledFootnote) .font(.scaledFootnote)
.foregroundStyle(.secondary) .foregroundColor(.gray)
} }
Spacer() Spacer()
makePurchaseButton(product: product) makePurchaseButton(product: product)
@ -218,9 +212,7 @@ struct SupportAppView: View {
} }
} }
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
private var restorePurchase: some View { private var restorePurchase: some View {
@ -229,7 +221,7 @@ struct SupportAppView: View {
Spacer() Spacer()
Button { Button {
Purchases.shared.restorePurchases { info, _ in Purchases.shared.restorePurchases { info, _ in
customerInfo = info self.customerInfo = info
} }
} label: { } label: {
Text("settings.support.restore-purchase.button") Text("settings.support.restore-purchase.button")
@ -239,9 +231,7 @@ struct SupportAppView: View {
} footer: { } footer: {
Text("settings.support.restore-purchase.explanation") Text("settings.support.restore-purchase.explanation")
} }
#if !os(visionOS)
.listRowBackground(theme.secondaryBackgroundColor) .listRowBackground(theme.secondaryBackgroundColor)
#endif
} }
private var linksSection: some View { private var linksSection: some View {
@ -261,23 +251,21 @@ struct SupportAppView: View {
.buttonStyle(.borderless) .buttonStyle(.borderless)
} }
} }
#if !os(visionOS)
.listRowBackground(theme.secondaryBackgroundColor) .listRowBackground(theme.secondaryBackgroundColor)
#endif
} }
private var loadingPlaceholder: some View { private var loadingPlaceholder: some View {
HStack { HStack {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("placeholder.loading.short") Text("placeholder.loading.short.")
.font(.scaledSubheadline) .font(.scaledSubheadline)
Text("settings.support.placeholder.loading-subtitle") Text("settings.support.placeholder.loading-subtitle")
.font(.scaledFootnote) .font(.scaledFootnote)
.foregroundStyle(.secondary) .foregroundColor(.gray)
} }
.padding(.vertical, 8) .padding(.vertical, 8)
} }
.redacted(reason: .placeholder) .redacted(reason: .placeholder)
.allowsHitTesting(false) .shimmering()
} }
} }

View file

@ -2,13 +2,11 @@ import DesignSystem
import Env import Env
import SwiftUI import SwiftUI
@MainActor
struct SwipeActionsSettingsView: View { struct SwipeActionsSettingsView: View {
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@Environment(UserPreferences.self) private var userPreferences @EnvironmentObject private var userPreferences: UserPreferences
var body: some View { var body: some View {
@Bindable var userPreferences = userPreferences
Form { Form {
Section { Section {
Label("settings.swipeactions.status.leading", systemImage: "arrow.right") Label("settings.swipeactions.status.leading", systemImage: "arrow.right")
@ -16,7 +14,7 @@ struct SwipeActionsSettingsView: View {
createStatusActionPicker(selection: $userPreferences.swipeActionsStatusLeadingLeft, createStatusActionPicker(selection: $userPreferences.swipeActionsStatusLeadingLeft,
label: "settings.swipeactions.primary") label: "settings.swipeactions.primary")
.onChange(of: userPreferences.swipeActionsStatusLeadingLeft) { _, action in .onChange(of: userPreferences.swipeActionsStatusLeadingLeft) { action in
if action == .none { if action == .none {
userPreferences.swipeActionsStatusLeadingRight = .none userPreferences.swipeActionsStatusLeadingRight = .none
} }
@ -31,7 +29,7 @@ struct SwipeActionsSettingsView: View {
createStatusActionPicker(selection: $userPreferences.swipeActionsStatusTrailingRight, createStatusActionPicker(selection: $userPreferences.swipeActionsStatusTrailingRight,
label: "settings.swipeactions.primary") label: "settings.swipeactions.primary")
.onChange(of: userPreferences.swipeActionsStatusTrailingRight) { _, action in .onChange(of: userPreferences.swipeActionsStatusTrailingRight) { action in
if action == .none { if action == .none {
userPreferences.swipeActionsStatusTrailingLeft = .none userPreferences.swipeActionsStatusTrailingLeft = .none
} }
@ -46,9 +44,7 @@ struct SwipeActionsSettingsView: View {
} footer: { } footer: {
Text("settings.swipeactions.status.explanation") Text("settings.swipeactions.status.explanation")
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
Section { Section {
Picker(selection: $userPreferences.swipeActionsIconStyle, label: Text("settings.swipeactions.icon-style")) { Picker(selection: $userPreferences.swipeActionsIconStyle, label: Text("settings.swipeactions.icon-style")) {
@ -64,19 +60,15 @@ struct SwipeActionsSettingsView: View {
} footer: { } footer: {
Text("settings.swipeactions.use-theme-colors-explanation") Text("settings.swipeactions.use-theme-colors-explanation")
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
.navigationTitle("settings.swipeactions.navigation-title") .navigationTitle("settings.swipeactions.navigation-title")
#if !os(visionOS) .scrollContentBackground(.hidden)
.scrollContentBackground(.hidden) .background(theme.secondaryBackgroundColor)
.background(theme.secondaryBackgroundColor)
#endif
} }
private func createStatusActionPicker(selection: Binding<StatusAction>, label: LocalizedStringKey) -> some View { private func createStatusActionPicker(selection: Binding<StatusAction>, label: LocalizedStringKey) -> some View {
Picker(selection: selection, label: Text(label)) { return Picker(selection: selection, label: Text(label)) {
Section { Section {
Text(StatusAction.none.displayName()).tag(StatusAction.none) Text(StatusAction.none.displayName()).tag(StatusAction.none)
} }

View file

@ -1,69 +0,0 @@
import DesignSystem
import Env
import SwiftUI
@MainActor
struct TabbarEntriesSettingsView: View {
@Environment(Theme.self) private var theme
@Environment(UserPreferences.self) private var userPreferences
@State private var tabs = iOSTabs.shared
var body: some View {
@Bindable var userPreferences = userPreferences
Form {
Section {
Picker("settings.tabs.first-tab", selection: $tabs.firstTab) {
ForEach(AppTab.allCases) { tab in
if tab == tabs.firstTab || !tabs.tabs.contains(tab) {
tab.label.tag(tab)
}
}
}
Picker("settings.tabs.second-tab", selection: $tabs.secondTab) {
ForEach(AppTab.allCases) { tab in
if tab == tabs.secondTab || !tabs.tabs.contains(tab) {
tab.label.tag(tab)
}
}
}
Picker("settings.tabs.third-tab", selection: $tabs.thirdTab) {
ForEach(AppTab.allCases) { tab in
if tab == tabs.thirdTab || !tabs.tabs.contains(tab) {
tab.label.tag(tab)
}
}
}
Picker("settings.tabs.fourth-tab", selection: $tabs.fourthTab) {
ForEach(AppTab.allCases) { tab in
if tab == tabs.fourthTab || !tabs.tabs.contains(tab) {
tab.label.tag(tab)
}
}
}
Picker("settings.tabs.fifth-tab", selection: $tabs.fifthTab) {
ForEach(AppTab.allCases) { tab in
if tab == tabs.fifthTab || !tabs.tabs.contains(tab) {
tab.label.tag(tab)
}
}
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Section {
Toggle("settings.display.show-tab-label", isOn: $userPreferences.showiPhoneTabLabel)
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.navigationTitle("settings.general.tabbarEntries")
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
#endif
}
}

View file

@ -1,50 +0,0 @@
import DesignSystem
import Env
import Models
import SwiftData
import SwiftUI
struct TagsGroupSettingView: View {
@Environment(\.modelContext) private var context
@Environment(RouterPath.self) private var routerPath
@Environment(Theme.self) private var theme
@Query(sort: \TagGroup.creationDate, order: .reverse) var tagGroups: [TagGroup]
var body: some View {
Form {
ForEach(tagGroups) { group in
Label(group.title, systemImage: group.symbolName)
.onTapGesture {
routerPath.presentedSheet = .editTagGroup(tagGroup: group, onSaved: nil)
}
}
.onDelete { indexes in
if let index = indexes.first {
context.delete(tagGroups[index])
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Button {
routerPath.presentedSheet = .addTagGroup
} label: {
Label("timeline.filter.add-tag-groups", systemImage: "plus")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.navigationTitle("timeline.filter.tag-groups")
.scrollContentBackground(.hidden)
#if !os(visionOS)
.background(theme.secondaryBackgroundColor)
#endif
.toolbar {
EditButton()
}
}
}

View file

@ -2,25 +2,31 @@ import DesignSystem
import Env import Env
import SwiftUI import SwiftUI
@MainActor
struct TranslationSettingsView: View { struct TranslationSettingsView: View {
@Environment(UserPreferences.self) private var preferences @EnvironmentObject private var preferences: UserPreferences
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@State private var apiKey: String = "" @State private var apiKey: String = ""
var body: some View { var body: some View {
Form { Form {
translationSelector Toggle(isOn: preferences.$alwaysUseDeepl) {
if preferences.preferredTranslationType == .useDeepl { Text("settings.translation.always-deepl")
}
.listRowBackground(theme.primaryBackgroundColor)
if preferences.alwaysUseDeepl {
Section("settings.translation.user-api-key") { Section("settings.translation.user-api-key") {
deepLPicker Picker("settings.translation.api-key-type", selection: $preferences.userDeeplAPIFree) {
Text("DeepL API Free").tag(true)
Text("DeepL API Pro").tag(false)
}
SecureField("settings.translation.user-api-key", text: $apiKey) SecureField("settings.translation.user-api-key", text: $apiKey)
.textContentType(.password) .textContentType(.password)
} }
#if !os(visionOS) .onAppear(perform: readValue)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
if apiKey.isEmpty { if apiKey.isEmpty {
Section { Section {
@ -29,103 +35,23 @@ struct TranslationSettingsView: View {
.foregroundColor(.red) .foregroundColor(.red)
} }
} }
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif
} }
} }
backgroundAPIKey
autoDetectSection Section {
Toggle(isOn: preferences.$autoDetectPostLanguage) {
Text("settings.translation.auto-detect-post-language")
}
} footer: {
Text("settings.translation.auto-detect-post-language-footer")
}
} }
.navigationTitle("settings.translation.navigation-title") .navigationTitle("settings.translation.navigation-title")
#if !os(visionOS) .scrollContentBackground(.hidden)
.scrollContentBackground(.hidden) .background(theme.secondaryBackgroundColor)
.background(theme.secondaryBackgroundColor) .onChange(of: apiKey, perform: writeNewValue)
#endif .onAppear(perform: updatePrefs)
.onChange(of: apiKey) {
writeNewValue()
}
.onAppear(perform: updatePrefs)
.onAppear(perform: readValue)
}
@ViewBuilder
private var translationSelector: some View {
@Bindable var preferences = preferences
Picker("Translation Service", selection: $preferences.preferredTranslationType) {
ForEach(allTTCases, id: \.self) { type in
Text(type.description).tag(type)
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
var allTTCases: [TranslationType] {
TranslationType.allCases.filter { type in
if type != .useApple {
return true
}
#if canImport(_Translation_SwiftUI)
if #available(iOS 17.4, *) {
return true
} else {
return false
}
#else
return false
#endif
}
}
@ViewBuilder
private var deepLPicker: some View {
@Bindable var preferences = preferences
Picker("settings.translation.api-key-type", selection: $preferences.userDeeplAPIFree) {
Text("DeepL API Free").tag(true)
Text("DeepL API Pro").tag(false)
}
}
@ViewBuilder
private var autoDetectSection: some View {
@Bindable var preferences = preferences
Section {
Toggle(isOn: $preferences.autoDetectPostLanguage) {
Text("settings.translation.auto-detect-post-language")
}
} footer: {
Text("settings.translation.auto-detect-post-language-footer")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
@ViewBuilder
private var backgroundAPIKey: some View {
if preferences.preferredTranslationType != .useDeepl,
!apiKey.isEmpty
{
Section {
Text("The DeepL API Key is still stored!")
if preferences.preferredTranslationType == .useServerIfPossible {
Text("It can however still be used as a fallback for your instance's translation service.")
}
Button(role: .destructive) {
withAnimation {
writeNewValue(value: "")
readValue()
}
} label: {
Text("action.delete")
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
} }
private func writeNewValue() { private func writeNewValue() {
@ -137,7 +63,11 @@ struct TranslationSettingsView: View {
} }
private func readValue() { private func readValue() {
apiKey = DeepLUserAPIHandler.readKey() if let apiKey = DeepLUserAPIHandler.readIfAllowed() {
self.apiKey = apiKey
} else {
apiKey = ""
}
} }
private func updatePrefs() { private func updatePrefs() {
@ -148,6 +78,6 @@ struct TranslationSettingsView: View {
struct TranslationSettingsView_Previews: PreviewProvider { struct TranslationSettingsView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
TranslationSettingsView() TranslationSettingsView()
.environment(UserPreferences.shared) .environmentObject(UserPreferences.shared)
} }
} }

View file

@ -1,8 +0,0 @@
import SwiftUI
import WishKit
struct WishlistView: View {
var body: some View {
WishKit.view
}
}

View file

@ -1,78 +1,54 @@
import Account import Account
import AppIntents
import DesignSystem import DesignSystem
import Explore import Explore
import Foundation import Foundation
import StatusKit import Status
import SwiftUI import SwiftUI
@MainActor enum Tab: Int, Identifiable, Hashable {
enum AppTab: Int, Identifiable, Hashable, CaseIterable, Codable {
case timeline, notifications, mentions, explore, messages, settings, other case timeline, notifications, mentions, explore, messages, settings, other
case trending, federated, local case trending, federated, local
case profile case profile
case bookmarks
case favorites
case post
case followedTags
case lists
case links
nonisolated var id: Int { var id: Int {
rawValue rawValue
} }
static func loggedOutTab() -> [AppTab] { static func loggedOutTab() -> [Tab] {
[.timeline, .settings] [.timeline, .settings]
} }
static func visionOSTab() -> [AppTab] { static func loggedInTabs() -> [Tab] {
[.profile, .timeline, .notifications, .mentions, .explore, .post, .settings] if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
return [.timeline, .trending, .federated, .local, .notifications, .mentions, .explore, .messages, .settings]
} else {
return [.timeline, .notifications, .explore, .messages, .profile]
}
} }
@ViewBuilder @ViewBuilder
func makeContentView(selectedTab: Binding<AppTab>) -> some View { func makeContentView(popToRootTab: Binding<Tab>) -> some View {
switch self { switch self {
case .timeline: case .timeline:
TimelineTab() TimelineTab(popToRootTab: popToRootTab)
case .trending: case .trending:
TimelineTab(timeline: .trending) TimelineTab(popToRootTab: popToRootTab, timeline: .trending)
case .local: case .local:
TimelineTab(timeline: .local) TimelineTab(popToRootTab: popToRootTab, timeline: .local)
case .federated: case .federated:
TimelineTab(timeline: .federated) TimelineTab(popToRootTab: popToRootTab, timeline: .federated)
case .notifications: case .notifications:
NotificationsTab(selectedTab: selectedTab, lockedType: nil) NotificationsTab(popToRootTab: popToRootTab, lockedType: nil)
case .mentions: case .mentions:
NotificationsTab(selectedTab: selectedTab, lockedType: .mention) NotificationsTab(popToRootTab: popToRootTab, lockedType: .mention)
case .explore: case .explore:
ExploreTab() ExploreTab(popToRootTab: popToRootTab)
case .messages: case .messages:
MessagesTab() MessagesTab(popToRootTab: popToRootTab)
case .settings: case .settings:
SettingsTabs(isModal: false) SettingsTabs(popToRootTab: popToRootTab)
case .profile: case .profile:
ProfileTab() ProfileTab(popToRootTab: popToRootTab)
case .bookmarks:
NavigationTab {
AccountStatusesListView(mode: .bookmarks)
}
case .favorites:
NavigationTab {
AccountStatusesListView(mode: .favorites)
}
case .followedTags:
NavigationTab {
FollowedTagsListView()
}
case .lists:
NavigationTab {
ListsListView()
}
case .links:
NavigationTab { TrendingLinksListView(cards: []) }
case .post:
VStack {}
case .other: case .other:
EmptyView() EmptyView()
} }
@ -80,194 +56,56 @@ enum AppTab: Int, Identifiable, Hashable, CaseIterable, Codable {
@ViewBuilder @ViewBuilder
var label: some View { var label: some View {
if self != .other {
Label(title, systemImage: iconName)
}
}
var title: LocalizedStringKey {
switch self { switch self {
case .timeline: case .timeline:
"tab.timeline" Label("tab.timeline", systemImage: iconName)
case .trending: case .trending:
"tab.trending" Label("tab.trending", systemImage: iconName)
case .local: case .local:
"tab.local" Label("tab.local", systemImage: iconName)
case .federated: case .federated:
"tab.federated" Label("tab.federated", systemImage: iconName)
case .notifications: case .notifications:
"tab.notifications" Label("tab.notifications", systemImage: iconName)
case .mentions: case .mentions:
"tab.mentions" Label("tab.notifications", systemImage: iconName)
case .explore: case .explore:
"tab.explore" Label("tab.explore", systemImage: iconName)
case .messages: case .messages:
"tab.messages" Label("tab.messages", systemImage: iconName)
case .settings: case .settings:
"tab.settings" Label("tab.settings", systemImage: iconName)
case .profile: case .profile:
"tab.profile" Label("tab.profile", systemImage: iconName)
case .bookmarks:
"accessibility.tabs.profile.picker.bookmarks"
case .favorites:
"accessibility.tabs.profile.picker.favorites"
case .post:
"menu.new-post"
case .followedTags:
"timeline.filter.tags"
case .lists:
"timeline.filter.lists"
case .links:
"explore.section.trending.links"
case .other: case .other:
"" EmptyView()
} }
} }
var iconName: String { var iconName: String {
switch self { switch self {
case .timeline: case .timeline:
"rectangle.stack" return "rectangle.stack"
case .trending: case .trending:
"chart.line.uptrend.xyaxis" return "chart.line.uptrend.xyaxis"
case .local: case .local:
"person.2" return "person.2"
case .federated: case .federated:
"globe.americas" return "globe.americas"
case .notifications: case .notifications:
"bell" return "bell"
case .mentions: case .mentions:
"at" return "at"
case .explore: case .explore:
"magnifyingglass" return "magnifyingglass"
case .messages: case .messages:
"tray" return "tray"
case .settings: case .settings:
"gear" return "gear"
case .profile: case .profile:
"person.crop.circle" return "person.crop.circle"
case .bookmarks:
"bookmark"
case .favorites:
"star"
case .post:
"square.and.pencil"
case .followedTags:
"tag"
case .lists:
"list.bullet"
case .links:
"newspaper"
case .other: case .other:
"" return ""
} }
} }
} }
@MainActor
@Observable
class SidebarTabs {
struct SidedebarTab: Hashable, Codable {
let tab: AppTab
var enabled: Bool
}
class Storage {
@AppStorage("sidebar_tabs") var tabs: [SidedebarTab] = [
.init(tab: .timeline, enabled: true),
.init(tab: .trending, enabled: true),
.init(tab: .federated, enabled: true),
.init(tab: .local, enabled: true),
.init(tab: .notifications, enabled: true),
.init(tab: .mentions, enabled: true),
.init(tab: .messages, enabled: true),
.init(tab: .explore, enabled: true),
.init(tab: .bookmarks, enabled: true),
.init(tab: .favorites, enabled: true),
.init(tab: .followedTags, enabled: true),
.init(tab: .lists, enabled: true),
.init(tab: .links, enabled: true),
.init(tab: .settings, enabled: true),
.init(tab: .profile, enabled: true),
]
}
private let storage = Storage()
public static let shared = SidebarTabs()
var tabs: [SidedebarTab] {
didSet {
storage.tabs = tabs
}
}
func isEnabled(_ tab: AppTab) -> Bool {
tabs.first(where: { $0.tab.id == tab.id })?.enabled == true
}
private init() {
tabs = storage.tabs
}
}
@MainActor
@Observable
class iOSTabs {
enum TabEntries: String {
case first, second, third, fourth, fifth
}
class Storage {
@AppStorage(TabEntries.first.rawValue) var firstTab = AppTab.timeline
@AppStorage(TabEntries.second.rawValue) var secondTab = AppTab.notifications
@AppStorage(TabEntries.third.rawValue) var thirdTab = AppTab.explore
@AppStorage(TabEntries.fourth.rawValue) var fourthTab = AppTab.links
@AppStorage(TabEntries.fifth.rawValue) var fifthTab = AppTab.profile
}
private let storage = Storage()
public static let shared = iOSTabs()
var tabs: [AppTab] {
[firstTab, secondTab, thirdTab, fourthTab, fifthTab]
}
var firstTab: AppTab {
didSet {
storage.firstTab = firstTab
}
}
var secondTab: AppTab {
didSet {
storage.secondTab = secondTab
}
}
var thirdTab: AppTab {
didSet {
storage.thirdTab = thirdTab
}
}
var fourthTab: AppTab {
didSet {
storage.fourthTab = fourthTab
}
}
var fifthTab: AppTab {
didSet {
storage.fifthTab = fifthTab
}
}
private init() {
firstTab = storage.firstTab
secondTab = storage.secondTab
thirdTab = storage.thirdTab
fourthTab = storage.fourthTab
fifthTab = storage.fifthTab
}
}

View file

@ -1,448 +0,0 @@
import Combine
import DesignSystem
import Env
import Models
import Network
import NukeUI
import SFSafeSymbols
import SwiftData
import SwiftUI
@MainActor
struct EditTagGroupView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var context
@Environment(Theme.self) private var theme
@State private var tagGroup: TagGroup
private let onSaved: ((TagGroup) -> Void)?
private let isNewGroup: Bool
@FocusState private var focusedField: Focus?
var body: some View {
NavigationStack {
Form {
Section {
TitleInputView(
title: $tagGroup.title,
titleValidationStatus: tagGroup.titleValidationStatus,
focusedField: $focusedField,
isNewGroup: isNewGroup
)
SymbolInputView(
selectedSymbol: $tagGroup.symbolName,
selectedSymbolValidationStatus: tagGroup.symbolNameValidationStatus,
focusedField: $focusedField
)
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Section("add-tag-groups.edit.tags") {
TagsInputView(
tags: $tagGroup.tags,
tagsValidationStatus: tagGroup.tagsValidationStatus,
focusedField: $focusedField
)
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.formStyle(.grouped)
.navigationTitle(
isNewGroup
? "timeline.filter.add-tag-groups"
: "timeline.filter.edit-tag-groups"
)
.navigationBarTitleDisplayMode(.inline)
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.scrollDismissesKeyboard(.interactively)
#endif
.toolbar {
CancelToolbarItem()
ToolbarItem(placement: .navigationBarTrailing) {
Button("action.save", action: { save() })
.disabled(!tagGroup.isValid)
}
}
.onAppear {
focusedField = .title
}
}
}
init(tagGroup: TagGroup = .emptyGroup(), onSaved: ((TagGroup) -> Void)? = nil) {
_tagGroup = State(wrappedValue: tagGroup)
self.onSaved = onSaved
isNewGroup = tagGroup.title.isEmpty
}
private func save() {
tagGroup.format()
context.insert(tagGroup)
onSaved?(tagGroup)
dismiss()
}
enum Focus {
case title
case symbol
case new
}
}
struct AddTagGroupView_Previews: PreviewProvider {
static var previews: some View {
let container = try? ModelContainer(for: TagGroup.self, configurations: ModelConfiguration())
// need to use `sheet` to show `symbolsSuggestionView` in preview
return Text(verbatim: "parent view for EditTagGroupView")
.sheet(isPresented: .constant(true)) {
EditTagGroupView()
.withEnvironments()
.modelContainer(container!)
}
}
}
private struct TitleInputView: View {
@Binding var title: String
let titleValidationStatus: TagGroup.TitleValidationStatus
@FocusState.Binding var focusedField: EditTagGroupView.Focus?
@Query var tagGroups: [TagGroup]
let isNewGroup: Bool
var body: some View {
VStack(alignment: .leading) {
TextField("add-tag-groups.edit.title.field", text: $title, axis: .horizontal)
.focused($focusedField, equals: .title)
.onSubmit {
focusedField = .symbol
}
if focusedField == .title, warningText != "" {
Text(warningText).warningLabel()
}
}
}
var warningText: LocalizedStringKey {
if case let .invalid(description) = titleValidationStatus {
return description
} else if
isNewGroup,
tagGroups.contains(where: { $0.title == title })
{
return "\(title) add-tag-groups.edit.title.field.warning.already-exists"
}
return ""
}
}
private struct SymbolInputView: View {
@State private var symbolQuery = ""
@Binding var selectedSymbol: String
let selectedSymbolValidationStatus: TagGroup.SymbolNameValidationStatus
@FocusState.Binding var focusedField: EditTagGroupView.Focus?
var body: some View {
VStack(alignment: .leading) {
HStack {
TextField("add-tag-groups.edit.icon.field", text: $symbolQuery, axis: .horizontal)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: .symbol)
.onSubmit {
if TagGroup.allSymbols.contains(symbolQuery) {
selectedSymbol = symbolQuery
}
focusedField = .new
}
.onChange(of: focusedField) {
symbolQuery = selectedSymbol
}
Image(systemName: selectedSymbol)
.frame(height: 30)
}
if case let .invalid(description) = selectedSymbolValidationStatus,
focusedField == .symbol
{
Text(description).warningLabel()
}
if focusedField == .symbol {
SymbolSearchResultsView(
symbolQuery: $symbolQuery,
selectedSymbol: $selectedSymbol,
focusedField: $focusedField
)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 40)
}
}
}
}
private struct TagsInputView: View {
@State private var newTag: String = ""
@Binding var tags: [String]
let tagsValidationStatus: TagGroup.TagsValidationStatus
@FocusState.Binding var focusedField: EditTagGroupView.Focus?
var body: some View {
ForEach(tags, id: \.self) { tag in
HStack {
Text(tag)
Spacer()
Button { deleteTag(tag) } label: {
Image(systemName: "trash")
.foregroundStyle(.red)
}
.buttonStyle(.plain)
}
}
.onDelete { indexes in
if let index = indexes.first {
let tag = tags[index]
deleteTag(tag)
}
}
// this `VStack` need to be here to overcome a SwiftUI bug
// "add new tag" `TextField` is not focused after adding the first tag
VStack(alignment: .leading) {
HStack {
// this condition is using to overcome a SwiftUI bug
// "add new tag" `TextField` is not focused after adding the first tag
if tags.isEmpty {
addNewTagTextField()
} else {
addNewTagTextField()
.onAppear { focusedField = .new }
}
Spacer()
if !newTag.isEmpty, !tags.contains(newTag) {
Button { addNewTag() } label: {
Image(systemName: "checkmark.circle.fill").tint(.green)
}
}
}
if focusedField == .new, warningText != "" {
Text(warningText).warningLabel()
}
}
var warningText: LocalizedStringKey {
if tags.contains(newTag) {
return "add-tag-groups.edit.tags.field.warning.duplicated-tag"
} else if case let .invalid(description) = tagsValidationStatus {
return description
}
return ""
}
}
private func addNewTagTextField() -> some View {
VStack(alignment: .leading) {
TextField("add-tag-groups.edit.tags.add", text: $newTag, axis: .horizontal)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onSubmit {
addNewTag()
}
.focused($focusedField, equals: .new)
}
}
private func addNewTag() {
addTag(newTag.trimmingCharacters(in: .whitespaces).lowercased())
newTag = ""
focusedField = .new
}
private func addTag(_ tag: String) {
guard !tag.isEmpty,
!tags.contains(tag)
else { return }
tags.append(tag)
tags.sort()
}
private func deleteTag(_ tag: String) {
tags.removeAll(where: { $0 == tag })
}
}
private struct SymbolSearchResultsView: View {
@Binding var symbolQuery: String
@Binding var selectedSymbol: String
@State private var results: [String] = []
@FocusState.Binding var focusedField: EditTagGroupView.Focus?
var body: some View {
Group {
switch validationStatus {
case .valid:
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(results, id: \.self) { name in
Button {
results = TagGroup.searchSymbol(for: symbolQuery, exclude: selectedSymbol)
selectedSymbol = name
symbolQuery = name
focusedField = .new
} label: {
Image(systemName: name)
}
.buttonStyle(.plain)
}
}
.animation(.spring(duration: 0.2), value: results)
}
.onAppear {
results = TagGroup.searchSymbol(for: symbolQuery, exclude: selectedSymbol)
}
case let .invalid(description):
Text(description)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.onAppear {
results = TagGroup.searchSymbol(for: symbolQuery, exclude: selectedSymbol)
}
.onChange(of: symbolQuery) {
results = TagGroup.searchSymbol(for: symbolQuery, exclude: selectedSymbol)
}
}
// MARK: search results validation
enum ValidationStatus: Equatable {
case valid
case invalid(description: LocalizedStringKey)
}
var validationStatus: ValidationStatus {
if results.isEmpty {
if symbolQuery == selectedSymbol,
!symbolQuery.isEmpty,
results.count == 0
{
.invalid(description: "\(symbolQuery) add-tag-groups.edit.tags.field.warning.search-results.already-selected")
} else {
.invalid(description: "add-tag-groups.edit.tags.field.warning.search-results.no-symbol-found")
}
} else {
.valid
}
}
}
extension TagGroup {
// MARK: title validation
enum TitleValidationStatus: Equatable {
case valid
case invalid(description: LocalizedStringKey)
}
var titleValidationStatus: TitleValidationStatus {
title.isEmpty
? .invalid(description: "add-tag-groups.edit.title.field.warning.empty-title")
: .valid
}
// MARK: symbolName validation
enum SymbolNameValidationStatus: Equatable {
case valid
case invalid(description: LocalizedStringKey)
}
var symbolNameValidationStatus: SymbolNameValidationStatus {
if symbolName.isEmpty {
return .invalid(description: "add-tag-groups.edit.title.field.warning.no-symbol-selected")
} else if !Self.allSymbols.contains(symbolName) {
return .invalid(description: "\(symbolName) add-tag-groups.edit.title.field.warning.invalid-sfsymbol-name")
}
return .valid
}
// MARK: tags validation
enum TagsValidationStatus: Equatable {
case valid
case invalid(description: LocalizedStringKey)
}
var tagsValidationStatus: TagsValidationStatus {
if tags.count < 2 {
return .invalid(description: "add-tag-groups.edit.tags.field.warning.number-of-tags")
}
return .valid
}
// MARK: TagGroup validation
var isValid: Bool {
titleValidationStatus == .valid
&& symbolNameValidationStatus == .valid
&& tagsValidationStatus == .valid
}
// MARK: format
func format() {
title = title.trimmingCharacters(in: .whitespacesAndNewlines)
tags = tags.map { $0.lowercased() }
}
// MARK: static members
static func emptyGroup() -> TagGroup {
TagGroup(title: "", symbolName: "", tags: [])
}
static func searchSymbol(for query: String, exclude excludedSymbol: String) -> [String] {
guard !query.isEmpty else { return [] }
return allSymbols.filter {
$0.contains(query) &&
$0 != excludedSymbol
}
}
static let allSymbols: [String] = SFSymbol.allSymbols.map { symbol in
symbol.rawValue
}
}
extension Text {
func warningLabel() -> Text {
font(.caption)
.foregroundStyle(.red)
}
}

View file

@ -4,15 +4,14 @@ import Env
import Models import Models
import Network import Network
import NukeUI import NukeUI
import Shimmer
import SwiftUI import SwiftUI
@MainActor
struct AddRemoteTimelineView: View { struct AddRemoteTimelineView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var context
@Environment(UserPreferences.self) private var preferences @EnvironmentObject private var preferences: UserPreferences
@Environment(Theme.self) private var theme @EnvironmentObject private var theme: Theme
@State private var instanceName: String = "" @State private var instanceName: String = ""
@State private var instance: Instance? @State private var instance: Instance?
@ -37,51 +36,44 @@ struct AddRemoteTimelineView: View {
.foregroundColor(.green) .foregroundColor(.green)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
if !instanceName.isEmpty && instance == nil {
Label("timeline.\(instanceName)-not-valid", systemImage: "xmark.seal.fill")
.foregroundColor(.red)
.listRowBackground(theme.primaryBackgroundColor)
}
Button { Button {
guard instance != nil else { return } guard instance != nil else { return }
context.insert(LocalTimeline(instance: instanceName)) preferences.remoteLocalTimelines.append(instanceName)
dismiss() dismiss()
} label: { } label: {
Text("timeline.add.action.add") Text("timeline.add.action.add")
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
.disabled(instance == nil)
instancesListView instancesListView
} }
.formStyle(.grouped) .formStyle(.grouped)
.navigationTitle("timeline.add-remote.title") .navigationTitle("timeline.add-remote.title")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#if !os(visionOS) .scrollContentBackground(.hidden)
.scrollContentBackground(.hidden) .background(theme.secondaryBackgroundColor)
.background(theme.secondaryBackgroundColor) .scrollDismissesKeyboard(.immediately)
.scrollDismissesKeyboard(.immediately) .toolbar {
#endif ToolbarItem(placement: .navigationBarLeading) {
.toolbar { Button("action.cancel", action: { dismiss() })
CancelToolbarItem()
} }
.onChange(of: instanceName) { _, newValue in }
instanceNamePublisher.send(newValue) .onChange(of: instanceName) { newValue in
instanceNamePublisher.send(newValue)
}
.onReceive(instanceNamePublisher.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)) { newValue in
Task {
let client = Client(server: newValue)
instance = try? await client.get(endpoint: Instances.instance)
} }
.onReceive(instanceNamePublisher.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)) { newValue in }
Task { .onAppear {
let client = Client(server: newValue) isInstanceURLFieldFocused = true
instance = try? await client.get(endpoint: Instances.instance) let client = InstanceSocialClient()
} Task {
} self.instances = await client.fetchInstances()
.onAppear {
isInstanceURLFieldFocused = true
let client = InstanceSocialClient()
let instanceName = instanceName
Task {
instances = await client.fetchInstances(keyword: instanceName)
}
} }
}
} }
} }
@ -93,7 +85,7 @@ struct AddRemoteTimelineView: View {
} else { } else {
ForEach(instanceName.isEmpty ? instances : instances.filter { $0.name.contains(instanceName.lowercased()) }) { instance in ForEach(instanceName.isEmpty ? instances : instances.filter { $0.name.contains(instanceName.lowercased()) }) { instance in
Button { Button {
instanceName = instance.name self.instanceName = instance.name
} label: { } label: {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(instance.name) Text(instance.name)
@ -101,13 +93,13 @@ struct AddRemoteTimelineView: View {
.foregroundColor(.primary) .foregroundColor(.primary)
Text(instance.info?.shortDescription ?? "") Text(instance.info?.shortDescription ?? "")
.font(.scaledBody) .font(.scaledBody)
.foregroundStyle(Color.secondary) .foregroundColor(.gray)
(Text("instance.list.users-\(instance.users)") (Text("instance.list.users-\(instance.users)")
+ Text("") + Text("")
+ Text("instance.list.posts-\(instance.statuses)")) + Text("instance.list.posts-\(instance.statuses)"))
.font(.scaledFootnote) .font(.scaledFootnote)
.foregroundStyle(Color.secondary) .foregroundColor(.gray)
} }
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)

View file

@ -0,0 +1,216 @@
import Combine
import DesignSystem
import Env
import Models
import Network
import NukeUI
import Shimmer
import SwiftUI
struct EditTagGroupView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var theme: Theme
@State private var title: String = ""
@State private var sfSymbolName: String = ""
@State private var tags: [String] = []
@State private var newTag: String = ""
@State private var popupTagsPresented = false
private var editingTagGroup: TagGroup?
private var onSaved: ((TagGroup) -> Void)?
private var canSave: Bool {
!title.isEmpty &&
// At least have 2 tags, one main and one additional.
tags.count >= 2
}
@FocusState private var focusedField: Focus?
enum Focus {
case title
case symbol
case new
}
init(editingTagGroup: TagGroup? = nil, onSaved: ((TagGroup) -> Void)? = nil) {
self.editingTagGroup = editingTagGroup
self.onSaved = onSaved
}
var body: some View {
NavigationStack {
ZStack(alignment: .bottom) {
Form {
metadataSection
keywordsSection
}
.formStyle(.grouped)
.navigationTitle(editingTagGroup != nil ? "timeline.filter.edit-tag-groups" : "timeline.filter.add-tag-groups")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.scrollDismissesKeyboard(.immediately)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("action.cancel", action: { dismiss() })
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("action.save", action: { save() })
.disabled(!canSave)
}
}
symbolsSuggestionView
}
.onAppear {
focusedField = .title
if let editingTagGroup {
title = editingTagGroup.title
sfSymbolName = editingTagGroup.sfSymbolName
tags = editingTagGroup.tags
}
}
}
}
@ViewBuilder
private var metadataSection: some View {
Section {
TextField("add-tag-groups.edit.title.field", text: $title, axis: .horizontal)
.focused($focusedField, equals: Focus.title)
.onSubmit {
focusedField = Focus.symbol
}
HStack {
TextField("add-tag-groups.edit.icon.field", text: $sfSymbolName, axis: .horizontal)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: Focus.symbol)
.onSubmit {
focusedField = Focus.new
}
.onChange(of: sfSymbolName) { _ in
popupTagsPresented = true
}
Image(systemName: sfSymbolName)
}
}
.listRowBackground(theme.primaryBackgroundColor)
}
private var keywordsSection: some View {
Section("add-tag-groups.edit.tags") {
ForEach(tags, id: \.self) { tag in
HStack {
Text(tag)
Spacer()
Button {
deleteTag(tag)
} label: {
Image(systemName: "trash")
.tint(.red)
}
}
}
.onDelete { indexes in
if let index = indexes.first {
let tag = tags[index]
deleteTag(tag)
}
}
HStack {
TextField("add-tag-groups.edit.tags.add", text: $newTag, axis: .horizontal)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onSubmit {
addNewTag()
}
.focused($focusedField, equals: Focus.new)
Spacer()
if !newTag.isEmpty {
Button {
addNewTag()
} label: {
Image(systemName: "checkmark.circle.fill")
.tint(.green)
}
}
}
}
.listRowBackground(theme.primaryBackgroundColor)
}
private func addNewTag() {
addTag(newTag.trimmingCharacters(in: .whitespaces))
newTag = ""
focusedField = Focus.new
}
private func addTag(_ tag: String) {
guard !tag.isEmpty else { return }
tags.append(tag)
}
private func deleteTag(_ tag: String) {
tags.removeAll(where: { $0 == tag })
}
private func save() {
var toSave = tags
let main = toSave.removeFirst()
let tagGroup: TagGroup = .init(
title: title.trimmingCharacters(in: .whitespaces),
sfSymbolName: sfSymbolName,
main: main,
additional: toSave
)
if let editingTagGroup,
let index = preferences.tagGroups.firstIndex(of: editingTagGroup) {
preferences.tagGroups[index] = tagGroup
} else {
preferences.tagGroups.append(tagGroup)
}
dismiss()
onSaved?(tagGroup)
}
@ViewBuilder
private var symbolsSuggestionView: some View {
if focusedField == .symbol && !sfSymbolName.isEmpty {
let filteredMatches = allSymbols
.filter { $0.contains(sfSymbolName) }
if !filteredMatches.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(filteredMatches, id: \.self) { symbolName in
Button {
sfSymbolName = symbolName
} label: {
Image(systemName: symbolName)
}
}
}
.padding(.horizontal, .layoutPadding)
}
.frame(height: 40)
.background(.ultraThinMaterial)
}
} else {
EmptyView()
}
}
}
struct AddTagGroupView_Previews: PreviewProvider {
static var previews: some View {
EditTagGroupView()
.withEnvironments()
}
}

View file

@ -0,0 +1,13 @@
//
// Symbols.swift
// IceCubesApp
//
// Created by Alejandro Martinez on 18/7/23.
//
import Foundation
import SFSafeSymbols
let allSymbols: [String] = SFSymbol.allSymbols.map { symbol in
symbol.rawValue
}

View file

@ -4,60 +4,51 @@ import DesignSystem
import Env import Env
import Models import Models
import Network import Network
import SwiftData
import SwiftUI import SwiftUI
import Timeline import Timeline
@MainActor
struct TimelineTab: View { struct TimelineTab: View {
@Environment(\.modelContext) private var context @EnvironmentObject private var appAccount: AppAccountsManager
@EnvironmentObject private var theme: Theme
@Environment(AppAccountsManager.self) private var appAccount @EnvironmentObject private var currentAccount: CurrentAccount
@Environment(Theme.self) private var theme @EnvironmentObject private var preferences: UserPreferences
@Environment(CurrentAccount.self) private var currentAccount @EnvironmentObject private var client: Client
@Environment(UserPreferences.self) private var preferences @StateObject private var routerPath = RouterPath()
@Environment(Client.self) private var client @Binding var popToRootTab: Tab
@State private var routerPath = RouterPath()
@State private var didAppear: Bool = false @State private var didAppear: Bool = false
@State private var timeline: TimelineFilter = .home @State private var timeline: TimelineFilter
@State private var selectedTagGroup: TagGroup? @State private var scrollToTopSignal: Int = 0
@Query(sort: \LocalTimeline.creationDate, order: .reverse) var localTimelines: [LocalTimeline] @AppStorage("last_timeline_filter") public var lastTimelineFilter: TimelineFilter = .home
@Query(sort: \TagGroup.creationDate, order: .reverse) var tagGroups: [TagGroup]
@AppStorage("last_timeline_filter") var lastTimelineFilter: TimelineFilter = .home
@AppStorage("timeline_pinned_filters") private var pinnedFilters: [TimelineFilter] = []
private let canFilterTimeline: Bool private let canFilterTimeline: Bool
init(timeline: TimelineFilter? = nil) { init(popToRootTab: Binding<Tab>, timeline: TimelineFilter? = nil) {
canFilterTimeline = timeline == nil canFilterTimeline = timeline == nil
_timeline = .init(initialValue: timeline ?? .home) self.timeline = timeline ?? .home
_popToRootTab = popToRootTab
} }
var body: some View { var body: some View {
NavigationStack(path: $routerPath.path) { NavigationStack(path: $routerPath.path) {
TimelineView(timeline: $timeline, TimelineView(timeline: $timeline, scrollToTopSignal: $scrollToTopSignal, canFilterTimeline: canFilterTimeline)
pinnedFilters: $pinnedFilters,
selectedTagGroup: $selectedTagGroup,
canFilterTimeline: canFilterTimeline)
.withAppRouter() .withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet) .withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
.toolbar { .toolbar {
toolbarView toolbarView
} }
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar) .toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
.id(client.id) .id(client.id)
} }
.onAppear { .onAppear {
routerPath.client = client routerPath.client = client
if !didAppear, canFilterTimeline { if !didAppear && canFilterTimeline {
didAppear = true didAppear = true
if client.isAuth { if client.isAuth {
timeline = lastTimelineFilter timeline = lastTimelineFilter
} else { } else {
timeline = .trending timeline = .federated
} }
} }
Task { Task {
@ -67,59 +58,118 @@ struct TimelineTab: View {
routerPath.presentedSheet = .addAccount routerPath.presentedSheet = .addAccount
} }
} }
.onChange(of: client.isAuth) { .onChange(of: client.isAuth, perform: { _ in
resetTimelineFilter() if client.isAuth {
timeline = lastTimelineFilter
} else {
timeline = .federated
}
})
.onChange(of: currentAccount.account?.id, perform: { _ in
if client.isAuth, canFilterTimeline {
timeline = lastTimelineFilter
} else {
timeline = .federated
}
})
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
if popToRootTab == .timeline {
if routerPath.path.isEmpty {
scrollToTopSignal += 1
} else {
routerPath.path = []
}
}
} }
.onChange(of: currentAccount.account?.id) { .onChange(of: client.id) { _ in
resetTimelineFilter()
}
.onChange(of: client.id) {
routerPath.path = [] routerPath.path = []
} }
.onChange(of: timeline) { _, newValue in .onChange(of: timeline) { timeline in
if client.isAuth, canFilterTimeline { if timeline == .home || timeline == .federated || timeline == .local {
lastTimelineFilter = newValue lastTimelineFilter = timeline
} }
switch newValue {
case let .tagGroup(title, _, _):
if let group = tagGroups.first(where: { $0.title == title }) {
selectedTagGroup = group
}
default:
selectedTagGroup = nil
}
}
.onReceive(NotificationCenter.default.publisher(for: .refreshTimeline)) { _ in
timeline = .latest
}
.onReceive(NotificationCenter.default.publisher(for: .trendingTimeline)) { _ in
timeline = .trending
}
.onReceive(NotificationCenter.default.publisher(for: .localTimeline)) { _ in
timeline = .local
}
.onReceive(NotificationCenter.default.publisher(for: .federatedTimeline)) { _ in
timeline = .federated
}
.onReceive(NotificationCenter.default.publisher(for: .homeTimeline)) { _ in
timeline = .home
} }
.withSafariRouter() .withSafariRouter()
.environment(routerPath) .environmentObject(routerPath)
} }
@ViewBuilder @ViewBuilder
private var timelineFilterButton: some View { private var timelineFilterButton: some View {
headerGroup if timeline.supportNewestPagination {
timelineFiltersButtons Button {
if client.isAuth { self.timeline = .latest
listsFiltersButons } label: {
tagsFiltersButtons Label(TimelineFilter.latest.localizedTitle(), systemImage: TimelineFilter.latest.iconName() ?? "")
}
.keyboardShortcut("r", modifiers: .command)
Divider()
}
ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in
Button {
self.timeline = timeline
} label: {
Label(timeline.localizedTitle(), systemImage: timeline.iconName() ?? "")
}
}
if !currentAccount.lists.isEmpty {
Menu("timeline.filter.lists") {
ForEach(currentAccount.sortedLists) { list in
Button {
timeline = .list(list: list)
} label: {
Label(list.title, systemImage: "list.bullet")
}
}
}
}
if !currentAccount.tags.isEmpty {
Menu("timeline.filter.tags") {
ForEach(currentAccount.sortedTags) { tag in
Button {
timeline = .hashtag(tag: tag.name, accountId: nil)
} label: {
Label("#\(tag.name)", systemImage: "number")
}
}
}
}
Menu("timeline.filter.local") {
ForEach(preferences.remoteLocalTimelines, id: \.self) { server in
Button {
timeline = .remoteLocal(server: server, filter: .local)
} label: {
VStack {
Label(server, systemImage: "dot.radiowaves.right")
}
}
}
Button {
routerPath.presentedSheet = .addRemoteLocalTimeline
} label: {
Label("timeline.filter.add-local", systemImage: "badge.plus.radiowaves.right")
}
}
Menu("timeline.filter.tag-groups") {
ForEach(preferences.tagGroups, id: \.self) { group in
Button {
timeline = .tagGroup(group)
} label: {
VStack {
let icon = group.sfSymbolName.isEmpty ? "number" : group.sfSymbolName
Label(group.title, systemImage: icon)
}
}
}
Button {
routerPath.presentedSheet = .addTagGroup
} label: {
Label("timeline.filter.add-tag-groups", systemImage: "plus")
}
} }
localTimelinesFiltersButtons
tagGroupsFiltersButtons
Divider()
contentFilterButton
} }
private var addAccountButton: some View { private var addAccountButton: some View {
@ -139,7 +189,17 @@ struct TimelineTab: View {
} }
} }
if client.isAuth { if client.isAuth {
ToolbarTab(routerPath: $routerPath) if UIDevice.current.userInterfaceIdiom != .pad {
ToolbarItem(placement: .navigationBarLeading) {
AppAccountsSelectorView(routerPath: routerPath)
.id(currentAccount.account?.id)
}
}
statusEditorToolbarItem(routerPath: routerPath,
visibility: preferences.postVisibility)
if UIDevice.current.userInterfaceIdiom == .pad && !preferences.showiPadSecondaryColumn {
SecondaryColumnToolbarItem()
}
} else { } else {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
addAccountButton addAccountButton
@ -174,149 +234,4 @@ struct TimelineTab: View {
} }
} }
} }
@ViewBuilder
private var headerGroup: some View {
ControlGroup {
if timeline.supportNewestPagination {
Button {
timeline = .latest
} label: {
Label(TimelineFilter.latest.localizedTitle(), systemImage: TimelineFilter.latest.iconName())
}
}
if timeline == .home {
Button {
timeline = .resume
} label: {
VStack {
Label(TimelineFilter.resume.localizedTitle(),
systemImage: TimelineFilter.resume.iconName())
}
}
}
pinButton
}
}
@ViewBuilder
private var pinButton: some View {
let index = pinnedFilters.firstIndex(where: { $0.id == timeline.id })
Button {
withAnimation {
if let index {
let timeline = pinnedFilters.remove(at: index)
Telemetry.signal("timeline.pin.removed", parameters: ["timeline" : timeline.rawValue])
} else {
pinnedFilters.append(timeline)
Telemetry.signal("timeline.pin.added", parameters: ["timeline" : timeline.rawValue])
}
}
} label: {
if index != nil {
Label("status.action.unpin", systemImage: "pin.slash")
} else {
Label("status.action.pin", systemImage: "pin")
}
}
}
private var timelineFiltersButtons: some View {
ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in
Button {
self.timeline = timeline
} label: {
Label(timeline.localizedTitle(), systemImage: timeline.iconName())
}
}
}
@ViewBuilder
private var listsFiltersButons: some View {
Menu("timeline.filter.lists") {
Button {
routerPath.presentedSheet = .listCreate
} label: {
Label("account.list.create", systemImage: "plus")
}
ForEach(currentAccount.sortedLists) { list in
Button {
timeline = .list(list: list)
} label: {
Label(list.title, systemImage: "list.bullet")
}
}
}
}
@ViewBuilder
private var tagsFiltersButtons: some View {
if !currentAccount.tags.isEmpty {
Menu("timeline.filter.tags") {
ForEach(currentAccount.sortedTags) { tag in
Button {
timeline = .hashtag(tag: tag.name, accountId: nil)
} label: {
Label("#\(tag.name)", systemImage: "number")
}
}
}
}
}
private var localTimelinesFiltersButtons: some View {
Menu("timeline.filter.local") {
ForEach(localTimelines) { remoteLocal in
Button {
timeline = .remoteLocal(server: remoteLocal.instance, filter: .local)
} label: {
VStack {
Label(remoteLocal.instance, systemImage: "dot.radiowaves.right")
}
}
}
Button {
routerPath.presentedSheet = .addRemoteLocalTimeline
} label: {
Label("timeline.filter.add-local", systemImage: "badge.plus.radiowaves.right")
}
}
}
private var tagGroupsFiltersButtons: some View {
Menu("timeline.filter.tag-groups") {
ForEach(tagGroups) { group in
Button {
timeline = .tagGroup(title: group.title, tags: group.tags, symbolName: group.symbolName)
} label: {
VStack {
let icon = group.symbolName.isEmpty ? "number" : group.symbolName
Label(group.title, systemImage: icon)
}
}
}
Button {
routerPath.presentedSheet = .addTagGroup
} label: {
Label("timeline.filter.add-tag-groups", systemImage: "plus")
}
}
}
private var contentFilterButton: some View {
Button(action: {
routerPath.presentedSheet = .timelineContentFilter
}, label: {
Label("timeline.content-filter.title", systemSymbol: .line3HorizontalDecrease)
})
}
private func resetTimelineFilter() {
if client.isAuth, canFilterTimeline {
timeline = lastTimelineFilter
} else if !client.isAuth {
timeline = .trending
}
}
} }

View file

@ -1,51 +0,0 @@
import AppAccount
import DesignSystem
import Env
import SwiftUI
@MainActor
struct ToolbarTab: ToolbarContent {
@Environment(\.isSecondaryColumn) private var isSecondaryColumn: Bool
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(UserPreferences.self) private var userPreferences
@Environment(Theme.self) private var theme
@Binding var routerPath: RouterPath
var body: some ToolbarContent {
if !isSecondaryColumn {
if horizontalSizeClass == .regular {
ToolbarItem(placement: .topBarLeading) {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
Button {
withAnimation {
userPreferences.isSidebarExpanded.toggle()
}
} label: {
if userPreferences.isSidebarExpanded {
Image(systemName: "sidebar.squares.left")
} else {
Image(systemName: "sidebar.left")
}
}
}
}
}
statusEditorToolbarItem(routerPath: routerPath,
visibility: userPreferences.postVisibility)
if UIDevice.current.userInterfaceIdiom != .pad ||
(UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact)
{
ToolbarItem(placement: .navigationBarLeading) {
AppAccountsSelectorView(routerPath: routerPath, avatarConfig: theme.avatarShape == .circle ? .badge : .badgeRounded)
}
}
}
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .regular {
if (!isSecondaryColumn && !userPreferences.showiPadSecondaryColumn) || isSecondaryColumn {
SecondaryColumnToolbarItem()
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

Before

Width:  |  Height:  |  Size: 846 KiB

After

Width:  |  Height:  |  Size: 846 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
IceCubesApp/Assets.xcassets/AppIcon.appiconset/128.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
IceCubesApp/Assets.xcassets/AppIcon.appiconset/16.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

BIN
IceCubesApp/Assets.xcassets/AppIcon.appiconset/256.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

BIN
IceCubesApp/Assets.xcassets/AppIcon.appiconset/32.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

BIN
IceCubesApp/Assets.xcassets/AppIcon.appiconset/512.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
IceCubesApp/Assets.xcassets/AppIcon.appiconset/64.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

View file

@ -1,98 +1,284 @@
{ {
"images" : [ "images": [
{ {
"filename" : "AppIcon-fs8.png", "size": "60x60",
"idiom" : "universal", "expected-size": "180",
"platform" : "ios", "filename": "180.png",
"size" : "1024x1024" "folder": "Assets.xcassets/AppIcon.appiconset/",
}, "idiom": "iphone",
{ "scale": "3x"
"appearances" : [ },
{ {
"appearance" : "luminosity", "size": "40x40",
"value" : "dark" "expected-size": "80",
} "filename": "80.png",
], "folder": "Assets.xcassets/AppIcon.appiconset/",
"filename" : "dark.png", "idiom": "iphone",
"idiom" : "universal", "scale": "2x"
"platform" : "ios", },
"size" : "1024x1024" {
}, "size": "40x40",
{ "expected-size": "120",
"appearances" : [ "filename": "120.png",
{ "folder": "Assets.xcassets/AppIcon.appiconset/",
"appearance" : "luminosity", "idiom": "iphone",
"value" : "tinted" "scale": "3x"
} },
], {
"filename" : "tinted.png", "size": "60x60",
"idiom" : "universal", "expected-size": "120",
"platform" : "ios", "filename": "120.png",
"size" : "1024x1024" "folder": "Assets.xcassets/AppIcon.appiconset/",
}, "idiom": "iphone",
{ "scale": "2x"
"filename" : "16.png", },
"idiom" : "mac", {
"scale" : "1x", "size": "57x57",
"size" : "16x16" "expected-size": "57",
}, "filename": "57.png",
{ "folder": "Assets.xcassets/AppIcon.appiconset/",
"filename" : "32.png", "idiom": "iphone",
"idiom" : "mac", "scale": "1x"
"scale" : "2x", },
"size" : "16x16" {
}, "size": "29x29",
{ "expected-size": "58",
"filename" : "32 1.png", "filename": "58.png",
"idiom" : "mac", "folder": "Assets.xcassets/AppIcon.appiconset/",
"scale" : "1x", "idiom": "iphone",
"size" : "32x32" "scale": "2x"
}, },
{ {
"filename" : "64.png", "size": "29x29",
"idiom" : "mac", "expected-size": "29",
"scale" : "2x", "filename": "29.png",
"size" : "32x32" "folder": "Assets.xcassets/AppIcon.appiconset/",
}, "idiom": "iphone",
{ "scale": "1x"
"filename" : "128.png", },
"idiom" : "mac", {
"scale" : "1x", "size": "29x29",
"size" : "128x128" "expected-size": "87",
}, "filename": "87.png",
{ "folder": "Assets.xcassets/AppIcon.appiconset/",
"filename" : "256 1.png", "idiom": "iphone",
"idiom" : "mac", "scale": "3x"
"scale" : "2x", },
"size" : "128x128" {
}, "size": "57x57",
{ "expected-size": "114",
"filename" : "256.png", "filename": "114.png",
"idiom" : "mac", "folder": "Assets.xcassets/AppIcon.appiconset/",
"scale" : "1x", "idiom": "iphone",
"size" : "256x256" "scale": "2x"
}, },
{ {
"filename" : "512 1.png", "size": "20x20",
"idiom" : "mac", "expected-size": "40",
"scale" : "2x", "filename": "40.png",
"size" : "256x256" "folder": "Assets.xcassets/AppIcon.appiconset/",
}, "idiom": "iphone",
{ "scale": "2x"
"filename" : "512.png", },
"idiom" : "mac", {
"scale" : "1x", "size": "20x20",
"size" : "512x512" "expected-size": "60",
}, "filename": "60.png",
{ "folder": "Assets.xcassets/AppIcon.appiconset/",
"filename" : "Content.png", "idiom": "iphone",
"idiom" : "mac", "scale": "3x"
"scale" : "2x", },
"size" : "512x512" {
} "size": "1024x1024",
], "filename": "1024.png",
"info" : { "expected-size": "1024",
"author" : "xcode", "idiom": "ios-marketing",
"version" : 1 "folder": "Assets.xcassets/AppIcon.appiconset/",
} "scale": "1x"
},
{
"size": "40x40",
"expected-size": "80",
"filename": "80.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "2x"
},
{
"size": "72x72",
"expected-size": "72",
"filename": "72.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "1x"
},
{
"size": "76x76",
"expected-size": "152",
"filename": "152.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "2x"
},
{
"size": "50x50",
"expected-size": "100",
"filename": "100.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "2x"
},
{
"size": "29x29",
"expected-size": "58",
"filename": "58.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "2x"
},
{
"size": "76x76",
"expected-size": "76",
"filename": "76.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "1x"
},
{
"size": "29x29",
"expected-size": "29",
"filename": "29.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "1x"
},
{
"size": "50x50",
"expected-size": "50",
"filename": "50.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "1x"
},
{
"size": "72x72",
"expected-size": "144",
"filename": "144.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "2x"
},
{
"size": "40x40",
"expected-size": "40",
"filename": "40.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "1x"
},
{
"size": "83.5x83.5",
"expected-size": "167",
"filename": "167.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "2x"
},
{
"size": "20x20",
"expected-size": "20",
"filename": "20.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "1x"
},
{
"size": "20x20",
"expected-size": "40",
"filename": "40.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "2x"
},
{
"size": "128x128",
"expected-size": "128",
"filename": "128.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "1x"
},
{
"size": "256x256",
"expected-size": "256",
"filename": "256.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "1x"
},
{
"size": "128x128",
"expected-size": "256",
"filename": "256.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "2x"
},
{
"size": "256x256",
"expected-size": "512",
"filename": "512.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "2x"
},
{
"size": "32x32",
"expected-size": "32",
"filename": "32.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "1x"
},
{
"size": "512x512",
"expected-size": "512",
"filename": "512.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "1x"
},
{
"size": "16x16",
"expected-size": "16",
"filename": "16.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "1x"
},
{
"size": "16x16",
"expected-size": "32",
"filename": "32.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "2x"
},
{
"size": "32x32",
"expected-size": "64",
"filename": "64.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "2x"
},
{
"size": "512x512",
"expected-size": "1024",
"filename": "1024.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "2x"
}
]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 764 KiB

View file

@ -1,13 +0,0 @@
{
"images" : [
{
"filename" : "Background.png",
"idiom" : "vision",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,17 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{
"filename" : "Front.solidimagestacklayer"
},
{
"filename" : "Mid.solidimagestacklayer"
},
{
"filename" : "Back.solidimagestacklayer"
}
]
}

View file

@ -1,13 +0,0 @@
{
"images" : [
{
"filename" : "Layer 1.png",
"idiom" : "vision",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 549 KiB

View file

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"idiom" : "vision",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

View file

@ -1,14 +0,0 @@
{
"images" : [
{
"filename" : "AppIcon-fs8.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

View file

@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "1024.png", "filename" : "icon.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"

Some files were not shown because too many files have changed in this diff Show more