From 99ef37365e5bfd1a8ab4a0ce5826be2c808fef2a Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Wed, 27 May 2026 18:50:56 +0200 Subject: [PATCH 1/7] Move SwiftNextcloudUI to NextcloudKit Signed-off-by: Milen Pivchev --- Package.resolved | 18 + Package.swift | 29 +- Sources/NextcloudKitUI/Color+readable.swift | 52 + .../Documentation.docc/Documentation.md | 41 + .../Documentation.docc/theme-settings.json | 22 + .../Features/QRCodeParsing.swift | 88 + .../Features/URLSanitizing.swift | 57 + Sources/NextcloudKitUI/Localizable.xcstrings | 5229 +++++++++++++++++ Sources/NextcloudKitUI/Models/Account.swift | 51 + .../NextcloudKitUI/Models/SharedAccount.swift | 47 + .../Modifiers/CodeScannerSheet.swift | 48 + .../Modifiers/SharedAccountsSheet.swift | 54 + .../Modifiers/WebViewSheet.swift | 54 + Sources/NextcloudKitUI/PreviewData.swift | 41 + .../NextcloudKitUI/ViewRepresentable.swift | 14 + .../Views/AccountButtonView.swift | 184 + .../Views/AlternativeLoginMethodsView.swift | 106 + .../NextcloudKitUI/Views/FormDetailView.swift | 64 + .../Views/ServerAddressView.swift | 356 ++ .../Views/SharedAccountsView.swift | 77 + Sources/NextcloudKitUI/Views/WebView.swift | 87 + .../QRCodeParsingTests.swift | 58 + .../URLSanitizingTests.swift | 49 + 23 files changed, 6823 insertions(+), 3 deletions(-) create mode 100644 Sources/NextcloudKitUI/Color+readable.swift create mode 100644 Sources/NextcloudKitUI/Documentation.docc/Documentation.md create mode 100644 Sources/NextcloudKitUI/Documentation.docc/theme-settings.json create mode 100644 Sources/NextcloudKitUI/Features/QRCodeParsing.swift create mode 100644 Sources/NextcloudKitUI/Features/URLSanitizing.swift create mode 100644 Sources/NextcloudKitUI/Localizable.xcstrings create mode 100644 Sources/NextcloudKitUI/Models/Account.swift create mode 100644 Sources/NextcloudKitUI/Models/SharedAccount.swift create mode 100644 Sources/NextcloudKitUI/Modifiers/CodeScannerSheet.swift create mode 100644 Sources/NextcloudKitUI/Modifiers/SharedAccountsSheet.swift create mode 100644 Sources/NextcloudKitUI/Modifiers/WebViewSheet.swift create mode 100644 Sources/NextcloudKitUI/PreviewData.swift create mode 100644 Sources/NextcloudKitUI/ViewRepresentable.swift create mode 100644 Sources/NextcloudKitUI/Views/AccountButtonView.swift create mode 100644 Sources/NextcloudKitUI/Views/AlternativeLoginMethodsView.swift create mode 100644 Sources/NextcloudKitUI/Views/FormDetailView.swift create mode 100644 Sources/NextcloudKitUI/Views/ServerAddressView.swift create mode 100644 Sources/NextcloudKitUI/Views/SharedAccountsView.swift create mode 100644 Sources/NextcloudKitUI/Views/WebView.swift create mode 100644 Tests/NextcloudKitUITests/QRCodeParsingTests.swift create mode 100644 Tests/NextcloudKitUITests/URLSanitizingTests.swift diff --git a/Package.resolved b/Package.resolved index cfe19b9c..a2cfc080 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,6 +9,15 @@ "version" : "5.10.2" } }, + { + "identity" : "codescanner", + "kind" : "remoteSourceControl", + "location" : "https://github.com/twostraws/CodeScanner.git", + "state" : { + "revision" : "5e886430238944c7200fc9e10dbf2d9550dba865", + "version" : "2.5.2" + } + }, { "identity" : "mocker", "kind" : "remoteSourceControl", @@ -18,6 +27,15 @@ "version" : "3.0.2" } }, + { + "identity" : "swiftlintplugins", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SimplyDanny/SwiftLintPlugins", + "state" : { + "revision" : "18d8d2d18e1668006d1d209e0b5f5e06c1da7cb5", + "version" : "0.63.3" + } + }, { "identity" : "swiftyjson", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index b8b488eb..33e1f451 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,7 @@ import PackageDescription let package = Package( name: "NextcloudKit", platforms: [ - .iOS(.v14), + .iOS(.v17), .macOS(.v11), .tvOS(.v14), .watchOS(.v7), @@ -20,17 +20,37 @@ let package = Package( .library( name: "NextcloudKit", targets: ["NextcloudKit"]), + .library( + name: "NextcloudKitUI", + targets: ["NextcloudKitUI"]) ], dependencies: [ .package(url: "https://github.com/WeTransfer/Mocker.git", .upToNextMajor(from: "3.0.2")), .package(url: "https://github.com/Alamofire/Alamofire", .upToNextMajor(from: "5.10.2")), .package(url: "https://github.com/SwiftyJSON/SwiftyJSON", .upToNextMajor(from: "5.0.2")), .package(url: "https://github.com/yahoojapan/SwiftyXMLParser", .upToNextMajor(from: "5.6.0")), + .package(url: "https://github.com/twostraws/CodeScanner.git", .upToNextMajor(from: "2.5.2")), + .package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", from: "0.63.2"), ], targets: [ .target( name: "NextcloudKit", - dependencies: ["Alamofire", "SwiftyJSON", "SwiftyXMLParser"]), + dependencies: ["Alamofire", "SwiftyJSON", "SwiftyXMLParser"], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency") + ], + plugins: [ + .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins"), + ]), + .target( + name: "NextcloudKitUI", + dependencies: ["NextcloudKit", "CodeScanner"], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency") + ], + plugins: [ + .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins"), + ]), .testTarget( name: "NextcloudKitUnitTests", dependencies: ["NextcloudKit", "Mocker"], @@ -39,6 +59,9 @@ let package = Package( ]), .testTarget( name: "NextcloudKitIntegrationTests", - dependencies: ["NextcloudKit", "Mocker"]) + dependencies: ["NextcloudKit", "Mocker"]), + .testTarget( + name: "NextcloudKitUITests", + dependencies: ["NextcloudKit", "NextcloudKitUI", "Mocker"]), ] ) diff --git a/Sources/NextcloudKitUI/Color+readable.swift b/Sources/NextcloudKitUI/Color+readable.swift new file mode 100644 index 00000000..9982bb95 --- /dev/null +++ b/Sources/NextcloudKitUI/Color+readable.swift @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +extension Color { + + /// + /// Computes a color which is expected to be readable when being rendered on a background colored in `self`. + /// + /// Uses the WCAG luminance formula to determine brightness. + /// + var readable: Color { + // Gamma correction + func adjust(_ channel: CGFloat) -> CGFloat { + if channel <= 0.03928 { + return channel / 12.92 + } else { + return pow((channel + 0.055) / 1.055, 2.4) + } + } + + #if os(iOS) + let uiColor = UIColor(self) + + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + + guard uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { + return .primary + } + #endif + + #if os(macOS) + guard let rgbColor = NSColor(self).usingColorSpace(.sRGB) else { + return .primary + } + + let red = rgbColor.redComponent + let green = rgbColor.greenComponent + let blue = rgbColor.blueComponent + #endif + + let luminance = 0.2126 * adjust(red) + 0.7152 * adjust(green) + 0.0722 * adjust(blue) + + if luminance < 0.5 { + return .white + } else { + return .black + } + } +} diff --git a/Sources/NextcloudKitUI/Documentation.docc/Documentation.md b/Sources/NextcloudKitUI/Documentation.docc/Documentation.md new file mode 100644 index 00000000..7705a111 --- /dev/null +++ b/Sources/NextcloudKitUI/Documentation.docc/Documentation.md @@ -0,0 +1,41 @@ +# ``SwiftNextcloudUI`` + +A multi-platform package to provide common user interface elements of apps by Nextcloud written in SwiftUI. + +## Overview + +Key traits of this package: + +- **Swift 6.1**. Latest language features independent from the consuming projects. +- **Multiplatform**. With iOS and iPad OS being targeted primarily, macOS is supported to and checked for compatibility and appearance. +- **Strict concurrency checks enabled**. Avoiding technical debt from the start as far as possible. +- **Built for the future**. Minimum requirements are iOS 17 and macOS 15. This enables the use of modern APIs which improve code and work significantly. [Nextcloud Notes](http://github.com/nextcloud/notes-ios) is the first app to adopt this and already requires iOS 17 by itself. macOS support is not planned officially but maintained as much as possible to keep the door open. +- **SwiftLint**. SwiftLint is used as a build tool plugin to enforce certain code style and quality. +- **Unit tests**. Existing projects did lack test coverage so these are considered where possible. +- **Separation of concerns**. This is a user interface package. It does not implement business logic. It does not even depend on [NextcloudKit](https://github.com/nextcloud/nextcloudkit). The delegate pattern and closures are used instead to leave business logic implementation to consumers of this library. +- **String catalogs**. The user interface naturally contains localizable strings. To leverage latest technologies and learnings, this package is using [string catalogs](https://developer.apple.com/documentation/xcode/localizing-and-varying-text-with-a-string-catalog) to _automatically on build_ update all the localizations in a single resource, including pluralization. +- **Localization**. The localizable texts in the user interface provided by this package are maintained on [Transifex](https://app.transifex.com/nextcloud/nextcloud/swiftnextcloudui/). + + +## Topics + +### Login + +- ``ServerAddressView`` +- ``AddAccountHandler`` +- ``BeginPollingHandler`` +- ``CancelPollingHandler`` + +### Account Menu + +- ``AccountButtonView`` + +### Generic Views + +- ``FormDetailView`` +- ``WebView`` + +### Data Models + +- ``Account`` +- ``SharedAccount`` diff --git a/Sources/NextcloudKitUI/Documentation.docc/theme-settings.json b/Sources/NextcloudKitUI/Documentation.docc/theme-settings.json new file mode 100644 index 00000000..335eb5c5 --- /dev/null +++ b/Sources/NextcloudKitUI/Documentation.docc/theme-settings.json @@ -0,0 +1,22 @@ +{ + "theme": { + "color": { + "documentation-intro-eyebrow": "rgba(255, 255, 255, 0.6)", + "documentation-intro-figure": "white", + "documentation-intro-fill": "#0082c9", + "documentation-intro-title": "white", + "fill": { + "dark": "#181818", + "light": "white" + }, + "link": "#0082c9", + "text": { + "dark": "#D8D8D8", + "light": "#222222" + } + }, + "typography": { + "html-font": "\"M1 PLUS 1\", \"Open Sans\", system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", sans-serif" + } + } +} diff --git a/Sources/NextcloudKitUI/Features/QRCodeParsing.swift b/Sources/NextcloudKitUI/Features/QRCodeParsing.swift new file mode 100644 index 00000000..6591b745 --- /dev/null +++ b/Sources/NextcloudKitUI/Features/QRCodeParsing.swift @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// +/// Errors specific to the parsing of Nextcloud login QR codes. +/// +enum QRCodeError: Error { + case implausibleLength + case invalidPrefix + case missingHost + case missingPassword + case missingUser +} + +/// +/// Logic for parsing the atomic data values from a composite string as provided through a QR code for logging in. +/// +protocol QRCodeParsing { + /// + /// Extract individual bits from a composite string as in an QR code for logging in. + /// + /// - Parameters: + /// - code: The raw string which is attained from a scanned QR code. + /// + /// - Returns: A tuple consisting of the user name, app password and server address in this exact order. + /// + func parse(_ code: String) throws -> (user: String, password: String, host: URL) +} + +extension QRCodeParsing { + func parse(_ code: String) throws -> (user: String, password: String, host: URL) { + let loginCodePrefix = "nc://login/" + + guard code.count > loginCodePrefix.count else { + throw QRCodeError.implausibleLength + } + + guard code.hasPrefix(loginCodePrefix) else { + throw QRCodeError.invalidPrefix + } + + guard code.contains("user:") else { + throw QRCodeError.missingUser + } + + guard code.contains("password:") else { + throw QRCodeError.missingPassword + } + + guard code.contains("server:") else { + throw QRCodeError.missingHost + } + + guard let prefixRange = code.range(of: loginCodePrefix) else { + throw QRCodeError.invalidPrefix + } + + let compositeKeyValuePairs = code[prefixRange.upperBound...].components(separatedBy: "&") + var decomposedKeyValuePairs = [String: String]() + + compositeKeyValuePairs.forEach { compositeKeyValuePair in + let parts = compositeKeyValuePair.split(separator: ":", maxSplits: 1) + + guard parts.count == 2 else { + return + } + + decomposedKeyValuePairs[String(parts[0])] = String(parts[1]) + } + + guard let user = decomposedKeyValuePairs["user"] else { + throw QRCodeError.missingUser + } + + guard let password = decomposedKeyValuePairs["password"] else { + throw QRCodeError.missingPassword + } + + guard let hostString = decomposedKeyValuePairs["server"], let host = URL(string: hostString) else { + throw QRCodeError.missingHost + } + + return (user: user, password: password, host: host) + } +} diff --git a/Sources/NextcloudKitUI/Features/URLSanitizing.swift b/Sources/NextcloudKitUI/Features/URLSanitizing.swift new file mode 100644 index 00000000..aa0c31f1 --- /dev/null +++ b/Sources/NextcloudKitUI/Features/URLSanitizing.swift @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// +/// Turn user input of a server address into a sanitized URL. +/// +/// - Returns: If `input` is a valid URL, then the sanitized `URL` and otherwise `nil`. +/// +protocol URLSanitizing { + func sanitize(_ input: String) -> URL? +} + +extension URLSanitizing { + func sanitize(_ input: String) -> URL? { + guard input.isEmpty == false else { + return nil + } + + guard var components = URLComponents(string: input) else { + return nil + } + + // Ensure HTTP(S) is used. + if let givenScheme = components.scheme { + if ["http", "https"].contains(givenScheme) == false { + return nil + } + } else { + components.scheme = "https" + } + + if components.path.isEmpty { + components.path = "/" + } else { + // Drop last path component, if it is a PHP script. + let pathComponents = components.path.split(separator: "/") + + if let lastPathComponent = pathComponents.last, lastPathComponent == "index.php" { + components.path = pathComponents.dropLast().joined(separator: "/") + } + + // Add trailing slash, if missing. + if components.path.hasSuffix("/") == false { + components.path = "\(components.path)/" + } + } + + guard let url = components.url else { + return nil + } + + return url + } +} diff --git a/Sources/NextcloudKitUI/Localizable.xcstrings b/Sources/NextcloudKitUI/Localizable.xcstrings new file mode 100644 index 00000000..101ae083 --- /dev/null +++ b/Sources/NextcloudKitUI/Localizable.xcstrings @@ -0,0 +1,5229 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Accounts" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accounts" + } + }, + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "an" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ast" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bg_BG" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bn_BD" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "br" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bs" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cs_CZ" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cy_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de_DE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "en_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_419" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_AR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CL" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_DO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_EC" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_GT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_HN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_NI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_SV" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_UY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "et_EE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eu" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fi_FI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ga" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuntais" + } + }, + "gd" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "gl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hi_IN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hsb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hu_HU" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hy" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ia" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ig" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "is" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ja_JP" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka_GE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kab" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "km" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "la" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lt_LT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ms_MY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "my" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nb_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nn_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "oc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ps" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "si" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sk_SK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sq" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr@latin" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sw" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "th_TH" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ug" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ur_PK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uz" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_CN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zu_ZA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } + }, + "Accounts from other Apps" : { + "comment" : "Button label\nNavigation bar title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accounts from other Apps" + } + }, + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "an" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ast" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bg_BG" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bn_BD" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "br" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bs" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cs_CZ" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cy_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de_DE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "en_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_419" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_AR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CL" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_DO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_EC" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_GT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_HN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_NI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_SV" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_UY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "et_EE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eu" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fi_FI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ga" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuntais ó Aipeanna eile" + } + }, + "gd" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "gl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hi_IN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hsb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hu_HU" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hy" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ia" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ig" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "is" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ja_JP" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka_GE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kab" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "km" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "la" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lt_LT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ms_MY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "my" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nb_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nn_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "oc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ps" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "si" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sk_SK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sq" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr@latin" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sw" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "th_TH" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ug" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ur_PK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uz" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_CN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zu_ZA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } + }, + "Login Failed" : { + "comment" : "Alert title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Login Failed" + } + }, + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "an" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ast" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bg_BG" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bn_BD" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "br" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bs" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cs_CZ" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cy_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de_DE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "en_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_419" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_AR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CL" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_DO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_EC" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_GT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_HN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_NI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_SV" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_UY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "et_EE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eu" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fi_FI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ga" : { + "stringUnit" : { + "state" : "translated", + "value" : "Theip ar Logáil Isteach" + } + }, + "gd" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "gl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hi_IN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hsb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hu_HU" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hy" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ia" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ig" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "is" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ja_JP" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka_GE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kab" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "km" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "la" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lt_LT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ms_MY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "my" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nb_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nn_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "oc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ps" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "si" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sk_SK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sq" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr@latin" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sw" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "th_TH" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ug" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ur_PK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uz" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_CN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zu_ZA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } + }, + "OK" : { + "comment" : "Button label for error alert dismissal.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "an" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ast" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bg_BG" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bn_BD" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "br" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bs" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cs_CZ" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cy_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de_DE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "en_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_419" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_AR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CL" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_DO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_EC" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_GT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_HN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_NI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_SV" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_UY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "et_EE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eu" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fi_FI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ga" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ceart go leor" + } + }, + "gd" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "gl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hi_IN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hsb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hu_HU" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hy" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ia" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ig" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "is" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ja_JP" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka_GE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kab" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "km" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "la" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lt_LT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ms_MY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "my" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nb_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nn_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "oc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ps" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "si" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sk_SK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sq" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr@latin" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sw" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "th_TH" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ug" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ur_PK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uz" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_CN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zu_ZA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } + }, + "Scan QR Code" : { + "comment" : "Button label\nNavigation bar title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scan QR Code" + } + }, + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "an" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ast" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bg_BG" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bn_BD" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "br" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bs" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cs_CZ" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cy_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de_DE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "en_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_419" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_AR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CL" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_DO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_EC" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_GT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_HN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_NI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_SV" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_UY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "et_EE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eu" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fi_FI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ga" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scanáil Cód QR" + } + }, + "gd" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "gl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hi_IN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hsb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hu_HU" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hy" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ia" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ig" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "is" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ja_JP" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka_GE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kab" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "km" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "la" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lt_LT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ms_MY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "my" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nb_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nn_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "oc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ps" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "si" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sk_SK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sq" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr@latin" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sw" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "th_TH" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ug" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ur_PK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uz" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_CN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zu_ZA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } + }, + "Server Address" : { + "comment" : "Label for text field.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server Address" + } + }, + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "an" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ast" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bg_BG" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bn_BD" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "br" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bs" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cs_CZ" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cy_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de_DE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "en_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_419" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_AR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CL" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_DO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_EC" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_GT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_HN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_NI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_SV" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_UY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "et_EE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eu" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fi_FI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ga" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seoladh an Fhreastalaí" + } + }, + "gd" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "gl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hi_IN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hsb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hu_HU" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hy" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ia" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ig" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "is" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ja_JP" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka_GE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kab" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "km" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "la" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lt_LT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ms_MY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "my" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nb_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nn_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "oc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ps" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "si" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sk_SK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sq" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr@latin" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sw" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "th_TH" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ug" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ur_PK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uz" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_CN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zu_ZA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } + }, + "The address of your Nextcloud web interface when you open it in your browser." : { + "comment" : "Label below the server address field in the login view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The address of your Nextcloud web interface when you open it in your browser." + } + }, + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "an" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ast" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bg_BG" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bn_BD" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "br" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bs" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cs_CZ" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cy_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de_DE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "en_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_419" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_AR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CL" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_DO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_EC" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_GT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_HN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_NI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_SV" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_UY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "et_EE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eu" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fi_FI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ga" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seoladh do chomhéadain gréasáin Nextcloud nuair a osclaíonn tú é i do bhrabhsálaí." + } + }, + "gd" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "gl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hi_IN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hsb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hu_HU" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hy" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ia" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ig" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "is" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ja_JP" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka_GE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kab" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "km" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "la" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lt_LT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ms_MY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "my" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nb_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nn_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "oc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ps" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "si" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sk_SK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sq" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr@latin" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sw" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "th_TH" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ug" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ur_PK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uz" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_CN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zu_ZA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } + }, + "The entered server address is invalid." : { + "comment" : "This is an error message.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The entered server address is invalid." + } + }, + "af" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "an" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ast" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bg_BG" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bn_BD" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "br" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "bs" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cs_CZ" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "cy_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "de_DE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "en_GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_419" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_AR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CL" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_CR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_DO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_EC" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_GT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_HN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_NI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_PY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_SV" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es_UY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "et_EE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "eu" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fi_FI" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ga" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tá an seoladh freastalaí a iontráladh neamhbhailí." + } + }, + "gd" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "gl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hi_IN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hsb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hu_HU" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hy" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ia" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ig" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "is" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ja_JP" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ka_GE" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kab" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "km" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "kn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "la" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lb" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lo" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lt_LT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "lv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mn" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "mr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ms_MY" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "my" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nb_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "nn_NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "oc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ps" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt_PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sc" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "si" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sk_SK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sq" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sr@latin" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "sw" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "th_TH" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ug" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "ur_PK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uz" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_CN" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh_TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zu_ZA" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Sources/NextcloudKitUI/Models/Account.swift b/Sources/NextcloudKitUI/Models/Account.swift new file mode 100644 index 00000000..4639a874 --- /dev/null +++ b/Sources/NextcloudKitUI/Models/Account.swift @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +/// +/// Data model for locally configured accounts. +/// +public struct Account: Identifiable, Sendable, Equatable { + /// + /// Unique identifier for a model instance during its lifetime in memory. + /// + /// This is required by `Identifiable` for iteration and identification in SwiftUI collections. + /// + public let id: String + + /// + /// The image to present for this account. + /// + public let image: Image + + /// + /// The host address of this account. + /// + public let host: URL + + /// + /// The display name of this account. Not to confuse with the user name used to login with. + /// + public let name: String + + /// + /// Create a new instance. + /// + /// - Parameters: + /// - name: See ``name``. + /// - host: See ``host``. + /// - image: See ``image``. + /// + public init(_ name: String, on host: URL, with image: Image) { + self.id = "\(name)@\(host)" + self.name = name + self.host = host + self.image = image + } + + public static func == (lhs: Account, rhs: Account) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Sources/NextcloudKitUI/Models/SharedAccount.swift b/Sources/NextcloudKitUI/Models/SharedAccount.swift new file mode 100644 index 00000000..55813a87 --- /dev/null +++ b/Sources/NextcloudKitUI/Models/SharedAccount.swift @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +/// +/// Data model for accounts from other apps to be offered to a user during the login flow. +/// +public struct SharedAccount: Identifiable, Sendable { + /// + /// Unique identifier for a model instance during its lifetime in memory. + /// + /// This is required by `Identifiable` for iteration and identification in SwiftUI collections. + /// + public let id: UUID + + /// + /// The image to present for this shared account. + /// + public let image: Image + + /// + /// The host address of this shared account. + /// + public let host: URL + + /// + /// The login name of this shared account. + /// + public let name: String + + /// + /// Create a new instance. + /// + /// - Parameters: + /// - name: See ``name``. + /// - host: See ``host``. + /// - image: See ``image``. + /// + public init(_ name: String, on host: URL, with image: Image) { + self.id = UUID() + self.name = name + self.host = host + self.image = image + } +} diff --git a/Sources/NextcloudKitUI/Modifiers/CodeScannerSheet.swift b/Sources/NextcloudKitUI/Modifiers/CodeScannerSheet.swift new file mode 100644 index 00000000..9e49527e --- /dev/null +++ b/Sources/NextcloudKitUI/Modifiers/CodeScannerSheet.swift @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import CodeScanner +import SwiftUI + +#if os(iOS) + +/// +/// A sheet with a camera view to scan QR codes for logging in. +/// Includes toolbar configuration and its appearance. +/// +struct CodeScannerSheet: ViewModifier { + @Environment(\.dismiss) private var dismiss + + @Binding var isPresentingCodeScanner: Bool + let completionHandler: (Result) -> Void + + init(isPresented: Binding, _ completionHandler: @escaping (Result) -> Void) { + self._isPresentingCodeScanner = isPresented + self.completionHandler = completionHandler + } + + func body(content: Content) -> some View { + content.sheet(isPresented: $isPresentingCodeScanner) { + NavigationStack { + CodeScannerView(codeTypes: [.qr], scanMode: .once, showViewfinder: true, completion: completionHandler) + .ignoresSafeArea() + .toolbarTitleDisplayMode(.inline) + .navigationTitle(String(localized: "Scan QR Code", comment: "Navigation bar title")) + } + } + } +} + +extension View { + /// + /// A custom and specialized sheet to scan a QR code. + /// + /// See ``CodeScannerSheet`` for its implementation. + /// + func codeScannerSheet(isPresented: Binding, _ completionHandler: @escaping (Result) -> Void) -> some View { + modifier(CodeScannerSheet(isPresented: isPresented, completionHandler)) + } +} + +#endif diff --git a/Sources/NextcloudKitUI/Modifiers/SharedAccountsSheet.swift b/Sources/NextcloudKitUI/Modifiers/SharedAccountsSheet.swift new file mode 100644 index 00000000..d9b7dd71 --- /dev/null +++ b/Sources/NextcloudKitUI/Modifiers/SharedAccountsSheet.swift @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +/// +/// The sheet wrapping ``SharedAccountsView``. +/// +struct SharedAccountsSheet: ViewModifier { + @Binding var isPresented: Bool + + var sharedAccounts: [SharedAccount] + let selectionHandler: (SharedAccount) -> Void + + init(isPresented: Binding, sharedAccounts: [SharedAccount], selectionHandler: @escaping (SharedAccount) -> Void) { + self._isPresented = isPresented + self.sharedAccounts = sharedAccounts + self.selectionHandler = selectionHandler + } + + func body(content: Content) -> some View { + let sharedAccountsView = SharedAccountsView(sharedAccounts: sharedAccounts, selectionHandler: selectionHandler) + + return content.sheet(isPresented: $isPresented) { + sharedAccountsView.presentationDetents([.medium]) + } + } +} + +extension View { + /// + /// Present a sheet to select an account already set up in another app of the same group. + /// + /// See ``SharedAccountsSheet`` for its implementation. + /// + func sharedAccountsSheet(isPresented: Binding, sharedAccounts: [SharedAccount], selectionHandler: @escaping (SharedAccount) -> Void) -> some View { + modifier(SharedAccountsSheet(isPresented: isPresented, sharedAccounts: sharedAccounts, selectionHandler: selectionHandler)) + } +} + +#if DEBUG + +#Preview { + ZStack { + Color.blue + .ignoresSafeArea() + } + .sharedAccountsSheet(isPresented: .constant(true), sharedAccounts: PreviewData.sharedAccounts) { _ in + print("Account selected!") + } +} + +#endif diff --git a/Sources/NextcloudKitUI/Modifiers/WebViewSheet.swift b/Sources/NextcloudKitUI/Modifiers/WebViewSheet.swift new file mode 100644 index 00000000..53b34db4 --- /dev/null +++ b/Sources/NextcloudKitUI/Modifiers/WebViewSheet.swift @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +/// +/// The sheet wrapping ``WebView``. +/// +struct WebSheet: ViewModifier { + let onDismiss: () -> Void + let userAgent: String? + + @Binding var initialURL: URL? + @Binding var isPresented: Bool + + init(initialURL: Binding, isPresented: Binding, userAgent: String?, onDismiss: @escaping () -> Void) { + self._initialURL = initialURL + self._isPresented = isPresented + self.onDismiss = onDismiss + self.userAgent = userAgent + } + + func body(content: Content) -> some View { + content.sheet(isPresented: $isPresented, onDismiss: onDismiss) { + WebView(initialURL: $initialURL, userAgent: userAgent) + .ignoresSafeArea() + #if os(macOS) + .frame(minWidth: 800, minHeight: 800) + #endif + } + } +} + +extension View { + /// + /// Present a ``WebView`` in a sheet. + /// + /// See ``WebSheet`` for the implementation. + /// + func webSheet(initialURL: Binding, isPresented: Binding, userAgent: String?, onDismiss: @escaping () -> Void) -> some View { + modifier(WebSheet(initialURL: initialURL, isPresented: isPresented, userAgent: userAgent, onDismiss: onDismiss)) + } +} + +#Preview { + ZStack { + Color.blue + .ignoresSafeArea() + } + .webSheet(initialURL: .constant(URL(string: "http://localhost:8080")), isPresented: .constant(true), userAgent: nil) { + print("Web sheet dismissed!") + } +} diff --git a/Sources/NextcloudKitUI/PreviewData.swift b/Sources/NextcloudKitUI/PreviewData.swift new file mode 100644 index 00000000..662debff --- /dev/null +++ b/Sources/NextcloudKitUI/PreviewData.swift @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +// swiftlint:disable force_unwrapping + +#if DEBUG + +import SwiftUI + +/// +/// Static data available only in debug configuration for SwiftUI previews to reduce redundant code. +/// +enum PreviewData { + /// + /// First item in ``accounts``. + /// + static var account: Account { + accounts.first! + } + + /// + /// An array of multiple account items. + /// + static let accounts: [Account] = [ + Account("Jane Doe", on: URL(string: "http://localhost:8080")!, with: Image(systemName: "bird.circle.fill")), + Account("Ariana Dane", on: URL(string: "http://localhost:33306")!, with: Image(systemName: "leaf.circle.fill")), + Account("Jean Derp", on: URL(string: "http://localhost:1414")!, with: Image(systemName: "person.circle.fill")) + ] + + /// + /// An array of multiple shared accounts. + /// + static let sharedAccounts: [SharedAccount] = [ + SharedAccount("jane", on: URL(string: "http://localhost:8080")!, with: Image(systemName: "person.circle.fill")), + SharedAccount("john", on: URL(string: "http://localhost:8081")!, with: Image(systemName: "bird.circle.fill")), + SharedAccount("jean", on: URL(string: "http://localhost:8082")!, with: Image(systemName: "leaf.circle.fill")) + ] +} + +#endif diff --git a/Sources/NextcloudKitUI/ViewRepresentable.swift b/Sources/NextcloudKitUI/ViewRepresentable.swift new file mode 100644 index 00000000..62709b5b --- /dev/null +++ b/Sources/NextcloudKitUI/ViewRepresentable.swift @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +/// +/// A multiplatform type alias for `NSViewRepresentable` and `UIViewRepresentable`. +/// +#if os(macOS) +typealias ViewRepresentable = NSViewRepresentable +#else +typealias ViewRepresentable = UIViewRepresentable +#endif diff --git a/Sources/NextcloudKitUI/Views/AccountButtonView.swift b/Sources/NextcloudKitUI/Views/AccountButtonView.swift new file mode 100644 index 00000000..84168391 --- /dev/null +++ b/Sources/NextcloudKitUI/Views/AccountButtonView.swift @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +/// +/// Account menu to be used in the toolbar. +/// +public struct AccountButtonView: View { + var accounts: [Account] + let supportsMultipleAccounts: Bool + + @Binding var activeAccount: Account? + @Binding var showLogin: Bool + @Binding var showSettings: Bool + + @State var showPopover: Bool = false + + /// + /// Set up a new account button view. + /// + /// - Parameters: + /// - activeAccount: The currently selected account. + /// - accounts: All accounts available for selection. + /// - showLogin: Whether the app should present a login view or not. + /// - showSettings: Whether the app should present a settings view or not. + /// - supportsMultipleAccounts: Whether the button should adapt for a multi-account presentation. This means the offering to add another account and showing a heading above the account list which otherwise contains a single item. + /// - showPopover: Whether the popover is presented by default or not. + /// + public init(activeAccount: Binding, accounts: [Account], showLogin: Binding, showSettings: Binding, supportsMultipleAccounts: Bool = true, showPopover: Bool = false) { + self._activeAccount = activeAccount + self.accounts = accounts + self._showLogin = showLogin + self._showSettings = showSettings + self.showPopover = showPopover + self.supportsMultipleAccounts = supportsMultipleAccounts + } + + public var body: some View { + Button { + showPopover.toggle() + } label: { + if let activeAccount { + activeAccount.image + } else { + Image(systemName: "questionmark.circle.dashed") + } + } + .popover(isPresented: $showPopover) { + VStack { + if supportsMultipleAccounts { + Text("Accounts") + .bold() + .padding(.top) + + Divider() + } else { + Spacer(minLength: 10) + } + + ForEach(accounts) { account in + Button { + selectAccount(account) + } label: { + HStack(spacing: 0) { + // Active account indicator + Image(systemName: "circle.fill") + .resizable() + .frame(width: 10, height: 10) + .foregroundStyle(activeAccount == account ? Color.accentColor : Color.clear) + .padding([.leading, .trailing], 10) + + // Account image + account.image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40) + .padding(.trailing, 10) + + // Account details + VStack(alignment: .leading) { + Text(verbatim: account.name) + .foregroundStyle(.primary) + + Text(verbatim: account.host.absoluteString) + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding(.trailing) + .padding(.vertical, 10) + } + .buttonStyle(.plain) + + Divider() + } + + HStack { + // Add account button + if supportsMultipleAccounts { + Button { + showLogin = true + } label: { + Image(systemName: "person.fill.badge.plus") + .padding(.top, 5) + .padding([.leading, .bottom, .trailing]) + } + .buttonStyle(.plain) + } + + Spacer() + + // Settings button + Button { + showSettings = true + } label: { + Image(systemName: "gear") + .padding(.top, 5) + .padding([.leading, .bottom, .trailing]) + } + .buttonStyle(.plain) + } + } + .presentationCompactAdaptation(.popover) + } + } + + func selectAccount(_ account: Account) { + activeAccount = account + showPopover = false + } +} + +#if DEBUG + +#Preview("Closed") { + @Previewable @State var selectedAccount: Account? = PreviewData.account + @Previewable @State var showLogin = false + @Previewable @State var showSettings = false + + NavigationStack { + Text("Closed Account Popover") + .toolbar { + ToolbarItem(placement: .navigation) { + AccountButtonView(activeAccount: $selectedAccount, accounts: [PreviewData.account], showLogin: $showLogin, showSettings: $showSettings, showPopover: false) + } + } + } +} + +#Preview("Single Account") { + @Previewable @State var selectedAccount: Account? = PreviewData.account + @Previewable @State var showLogin = false + @Previewable @State var showSettings = false + + NavigationStack { + Text("Single Account") + .toolbar { + ToolbarItem(placement: .navigation) { + AccountButtonView(activeAccount: $selectedAccount, accounts: [PreviewData.account], showLogin: $showLogin, showSettings: $showSettings, supportsMultipleAccounts: false, showPopover: true) + } + } + } +} + +#Preview("Multiple Accounts") { + @Previewable @State var selectedAccount: Account? = PreviewData.account + @Previewable @State var showLogin = false + @Previewable @State var showSettings = false + + NavigationStack { + Text("Multiple Accounts") + .toolbar { + ToolbarItem(placement: .navigation) { + AccountButtonView(activeAccount: $selectedAccount, accounts: PreviewData.accounts, showLogin: $showLogin, showSettings: $showSettings, showPopover: true) + } + } + } +} + +#endif diff --git a/Sources/NextcloudKitUI/Views/AlternativeLoginMethodsView.swift b/Sources/NextcloudKitUI/Views/AlternativeLoginMethodsView.swift new file mode 100644 index 00000000..0b35ba1f --- /dev/null +++ b/Sources/NextcloudKitUI/Views/AlternativeLoginMethodsView.swift @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import CodeScanner +import SwiftUI + +/// +/// The QR code scan and shared account selection buttons in ``ServerAddressView``. +/// +/// The visibility of the shared accounts button is determined based on the availability of shared accounts. +/// +struct AlternativeLoginMethodsView: View { + #if os(iOS) + let scanHandler: (Result) -> Void + #endif + + let selectionHandler: (SharedAccount) -> Void + + /// + /// Whether the app found shared accounts or not. + /// + var sharedAccounts: [SharedAccount] + + #if os(iOS) + /// + /// State toggle for presenting the QR code scanner sheet. + /// + @State var isPresentingCodeScanner = false + #endif + + /// + /// State toggle whether the shared accounts have been suggested once during lifetime of this view. + /// + @State var hasSuggestedSharedAccounts = false + + /// + /// State toggle for presenting the sheet to select accounts shared among apps of the same group. + /// + @State var isPresentingSharedAccounts = false + + #if os(iOS) + init(sharedAccounts: [SharedAccount], scanHandler: @escaping (Result) -> Void, selectionHandler: @escaping (SharedAccount) -> Void) { + self.sharedAccounts = sharedAccounts + self.scanHandler = scanHandler + self.selectionHandler = selectionHandler + } + #else + init(sharedAccounts: [SharedAccount], selectionHandler: @escaping (SharedAccount) -> Void) { + self.sharedAccounts = sharedAccounts + self.selectionHandler = selectionHandler + } + #endif + + var body: some View { + VStack(alignment: .leading) { + Spacer() + + HStack { + Spacer() + + VStack(alignment: .leading) { + #if os(iOS) // Camera usually is available on iOS devices only. + Button { + isPresentingCodeScanner = true + } label: { + Image(systemName: "qrcode.viewfinder") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + + Text(String(localized: "Scan QR Code", comment: "Button label")) + } + .padding() + .codeScannerSheet(isPresented: $isPresentingCodeScanner, scanHandler) + #endif + + if sharedAccounts.isEmpty == false { + Button { + isPresentingSharedAccounts = true + } label: { + Image(systemName: "person.fill.badge.plus") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + + Text(String(localized: "Accounts from other Apps", comment: "Button label")) + } + .padding() + .sharedAccountsSheet(isPresented: $isPresentingSharedAccounts, sharedAccounts: sharedAccounts, selectionHandler: selectionHandler) + .onAppear { + if sharedAccounts.isEmpty == false && hasSuggestedSharedAccounts == false { + isPresentingSharedAccounts = true + hasSuggestedSharedAccounts = true + } + } + } + } + + Spacer() + } + + Spacer() + } + } +} diff --git a/Sources/NextcloudKitUI/Views/FormDetailView.swift b/Sources/NextcloudKitUI/Views/FormDetailView.swift new file mode 100644 index 00000000..c60af5c7 --- /dev/null +++ b/Sources/NextcloudKitUI/Views/FormDetailView.swift @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +/// +/// Present a text in style of key-value text line in a SwiftUI form. +/// +public struct FormDetailView: View { + let onTapGesture: (() -> Void)? + let title: LocalizedStringKey + + @Binding var detail: String + + /// + /// - Parameters: + /// - title: Shown at the leading end as the label for the detail presented. + /// - detail: Shown at the trailing end as the detail for the label presented. + /// - onTapGesture: An optional tap handler. + /// + public init(_ title: LocalizedStringKey, detail: Binding, onTapGesture: (() -> Void)? = nil) { + self.title = title + self._detail = detail + self.onTapGesture = onTapGesture + } + + public var body: some View { + HStack { + Text(title) + + if detail.isEmpty == false { + Spacer() + + Text(detail) + .foregroundStyle(.secondary) + } + } + .contentShape(Rectangle()) // Required to make the whole HStack tappable. + .onTapGesture { + if let onTapGesture { + onTapGesture() + } + } + } +} + +#Preview { + Form { + Section("Without Detail") { + FormDetailView("Title", detail: .constant("")) + } + + Section("With Detail") { + FormDetailView("Title", detail: .constant("Detail")) + } + + Section("Tappable") { + FormDetailView("Title", detail: .constant("Tap Me")) { + print("FormDetailView was tapped!") + } + } + } +} diff --git a/Sources/NextcloudKitUI/Views/ServerAddressView.swift b/Sources/NextcloudKitUI/Views/ServerAddressView.swift new file mode 100644 index 00000000..11243dad --- /dev/null +++ b/Sources/NextcloudKitUI/Views/ServerAddressView.swift @@ -0,0 +1,356 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import CodeScanner +import SwiftUI + +/// +/// A new remote user account should be added locally in the persistence of the app. +/// +/// This method is required, in example by the QR code scan. The scan happens in domain of this package and circumvents the polling logic of the hosting app. +/// +public typealias AddAccountHandler = (_ host: URL, _ name: String, _ password: String) -> Void + +/// +/// The handler should start polling for the login flow status. +/// +/// - Parameters: +/// - url: The address of the server on which a login flow is to be started. +/// - dismiss: The handler to call when the login user interface should be dismissed. +/// +/// - Returns: The delegate must return the address on which the user should enter the login flow which then will be navigated to in the web view of this package. +/// +public typealias BeginPollingHandler = (_ url: URL, _ dismiss: @MainActor @Sendable @escaping () -> Void) async throws -> URL + +/// +/// The login process was cancelled. This can happen intentionally by the user dismissing the related views. The polling can be stopped. +/// +/// - Parameters: +/// - token: The polling token the login flow is identified by uniquely. +/// +public typealias CancelPollingHandler = () -> Void + +/// +/// The full screen view in which a user enters the address of the server to log in on. +/// +public struct ServerAddressView: View, QRCodeParsing, URLSanitizing { + let addAccount: AddAccountHandler + let beginPolling: BeginPollingHandler + let cancelPolling: CancelPollingHandler + let brandImage: Image + var sharedAccounts: [SharedAccount] + let userAgent: String? + + /// + /// Create a new server address view. + /// + /// - Parameters: + /// - backgroundColor: The main theme color the view should use. Foreground color will be adapted automatically based on this. + /// - brandImage: The image to display on top of the server address view. Falls back to an SF Symbol placeholder in case of `nil`. + /// - sharedAccounts: Any shared accounts from the app group being available for selection and faster login. + /// - userAgent: An optional user agent string to override the one used by ``WKWebView``. + /// - addAccount: see ``AddAccountHandler``. + /// - beginPolling: see ``BeginPollingHandler``. + /// - cancelPolling: see ``CancelPollingHandler``. + /// + public init(backgroundColor: Binding, brandImage: Image, sharedAccounts: [SharedAccount], userAgent: String? = nil, addAccount: @escaping AddAccountHandler, beginPolling: @escaping BeginPollingHandler, cancelPolling: @escaping CancelPollingHandler) { + self._backgroundColor = backgroundColor + self.addAccount = addAccount + self.beginPolling = beginPolling + self.cancelPolling = cancelPolling + self.brandImage = brandImage + self.sharedAccounts = sharedAccounts + self.userAgent = userAgent + } + + // MARK: - Environment + + @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @Environment(\.verticalSizeClass) var verticalSizeClass + + // MARK: - Bindings + + @Binding var backgroundColor: Color + + // MARK: - State + + /// + /// The unsanitized user input. + /// + @State var enteredServerAddress = "" + + /// + /// Message to display in case of error. + /// + @State var errorMessage: String? + + /// + /// Whether there currently is an activity which requires disabling the user interface. + /// + @State var isActive = false + + /// + /// State toggle for presenting the error alert. + /// + @State var isPresentingAlert = false + + /// + /// State toggle for presenting the web view sheet. + /// + @State var isPresentingWebView = false + + /// + /// The login address acquired by the server through the login flow API. + /// + @State var loginAddress: URL? + + // MARK: - Implementation + + public var body: some View { + ZStack { + backgroundColor + + VStack { + Spacer(minLength: 40) + + // Brand image binding or fallback symbol. + brandImage + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(backgroundColor.readable) + .frame(minHeight: 100) + .padding(.vertical, 40) + + // Some space between brand logo and server address field. + if verticalSizeClass == .regular { + Spacer() + .frame(height: 50) + } + + // Container to add horizontal spacers for regular size classes. + HStack { + if horizontalSizeClass == .regular { + Spacer(minLength: 100) + } + + // Container for the server address input and button. + HStack { + TextField( + text: $enteredServerAddress, + prompt: Text(verbatim: "https://example.org/").foregroundColor(backgroundColor.readable.opacity(0.5)) + ) { + Text("Server Address", comment: "Label for text field.") + } + .textContentType(.URL) + .autocorrectionDisabled() + #if os(iOS) + .foregroundStyle(backgroundColor.readable) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .submitLabel(.done) + #endif + .onSubmit { + sanitizeEnteredServerAddressAndLogIn() + } + + if isActive { + ProgressView() + .progressViewStyle(.circular) + .tint(backgroundColor.readable) + } else { + Button { + sanitizeEnteredServerAddressAndLogIn() + } label: { + #if !os(macOS) + Image(systemName: "arrow.right") + #else + Image(systemName: "arrow.right.circle") + .font(.title) + .foregroundStyle(.white) + #endif + } + #if os(macOS) + .buttonStyle(.plain) + #endif + } + } + #if !os(macOS) + .padding(EdgeInsets(top: 8, leading: 10, bottom: 8, trailing: 10)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(backgroundColor.readable, lineWidth: 1) + ) + #endif + + if horizontalSizeClass == .regular { + Spacer(minLength: 100) + } + } + + Text("The address of your Nextcloud web interface when you open it in your browser.", comment: "Label below the server address field in the login view.") + .foregroundStyle(backgroundColor.readable) + .font(.footnote) + .padding(4) + + Spacer() + + // Buttons for QR code and shared accounts. + #if os(iOS) + AlternativeLoginMethodsView(sharedAccounts: sharedAccounts, scanHandler: handleQRCodeScan, selectionHandler: handleSharedAccountSelection) + #else + AlternativeLoginMethodsView(sharedAccounts: sharedAccounts, selectionHandler: handleSharedAccountSelection) + #endif + + Spacer() + } + .disabled(isActive) + .tint(backgroundColor.readable) + .padding() + .safeAreaPadding(.all) + } + .ignoresSafeArea() + .webSheet(initialURL: $loginAddress, isPresented: $isPresentingWebView, userAgent: userAgent, onDismiss: endWebView) + .alert(String(localized: "Login Failed", comment: "Alert title"), isPresented: $isPresentingAlert) { + Button(role: .cancel) { + errorMessage = nil + } label: { + Text("OK", comment: "Button label for error alert dismissal.") + } + } message: { + Text(errorMessage ?? "?") + } + } + + // MARK: - Logic + + #if os(iOS) + func handleQRCodeScan(_ result: Result) { + switch result { + case .success(let result): + do { + let (name, password, host) = try parse(result.string) + addAccount(host, name, password) + } catch { + errorMessage = error.localizedDescription + isPresentingAlert = true + return + } + case .failure(let error): + errorMessage = error.localizedDescription + } + } + #endif + + func handleSharedAccountSelection(_ account: SharedAccount) { + enteredServerAddress = account.host.absoluteString + sanitizeEnteredServerAddressAndLogIn(user: account.name) + } + + /// + /// Clean up the received user input and dispatch the login flow. + /// + /// - Parameters: + /// - user: An optional user name to fill out automatically in the web user interface during the login. + /// + func sanitizeEnteredServerAddressAndLogIn(user: String? = nil) { + guard enteredServerAddress.trimmingCharacters(in: .whitespaces).isEmpty == false else { + return + } + + guard let sanitizedServerAddress = sanitize(enteredServerAddress) else { + errorMessage = String(localized: "The entered server address is invalid.", comment: "This is an error message.") + isPresentingAlert = true + return + } + + Task { + do { + var url = try await beginPolling(sanitizedServerAddress) { + isPresentingWebView = false + dismiss() + } + +// Temporary disabled until https://github.com/nextcloud/server/issues/59874 is resolved. +// if let user { +// url.append(queryItems: [URLQueryItem(name: "user", value: user)]) +// } + + beginWebView(url) + } catch { + endLogin(error) + } + } + } + + func beginWebView(_ url: URL) { + self.loginAddress = url + isPresentingWebView = true + } + + /// + /// Dismisses the sheet with the web view. + /// + /// Multiple paths can lead to here. In example: + /// + /// - The user dismisses the sheet without logging in. + /// - The login completed successfully. + /// + func endWebView() { + cancelPolling() + + isActive = false + isPresentingWebView = false + } + + /// + /// Completing the login process regardless of success or failure. + /// + func endLogin(_ error: Error? = nil) { + endWebView() + + if let error { + errorMessage = error.localizedDescription + isPresentingAlert = true + } + } +} + +#if DEBUG + +// swiftlint:disable force_unwrapping + +#Preview("Without Shared Accounts") { + let backgroundColor: Binding = .constant(.accentColor) + let brandImage = Image(systemName: "questionmark.square.dashed") + let sharedAccounts = [SharedAccount]() + + ServerAddressView(backgroundColor: backgroundColor, brandImage: brandImage, sharedAccounts: sharedAccounts) { _, _, _ in + print("Add account!") + } beginPolling: { _, _ in + print("Begin polling!") + return URL(string: "about:blank")! + } cancelPolling: { + print("Cancel polling!") + } +} + +#Preview("With Shared Accounts") { + let backgroundColor: Binding = .constant(.accentColor) + let brandImage = Image(systemName: "questionmark.square.dashed") + let sharedAccounts = [ + SharedAccount("Jane Doe", on: URL(string: "http://localhost:8080")!, with: Image(systemName: "person.circle.fill")) + ] + + ServerAddressView(backgroundColor: backgroundColor, brandImage: brandImage, sharedAccounts: sharedAccounts) { _, _, _ in + print("Add account!") + } beginPolling: { _, _ in + print("Begin polling!") + return URL(string: "about:blank")! + } cancelPolling: { + print("Cancel polling!") + } +} + +#endif diff --git a/Sources/NextcloudKitUI/Views/SharedAccountsView.swift b/Sources/NextcloudKitUI/Views/SharedAccountsView.swift new file mode 100644 index 00000000..cf883837 --- /dev/null +++ b/Sources/NextcloudKitUI/Views/SharedAccountsView.swift @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +/// +/// The view informing the user about shared accounts available for selection and offering a list to select from. +/// +struct SharedAccountsView: View { + @Environment(\.dismiss) private var dismiss + + var sharedAccounts: [SharedAccount] + + let selectionHandler: (SharedAccount) -> Void + + init(sharedAccounts: [SharedAccount], selectionHandler: @escaping (SharedAccount) -> Void) { + self.selectionHandler = selectionHandler + self.sharedAccounts = sharedAccounts + } + + var body: some View { + NavigationStack { + VStack { + ScrollView { + ForEach(sharedAccounts) { sharedAccount in + Button { + selectionHandler(sharedAccount) + dismiss() + } label: { + HStack { + sharedAccount + .image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40) + .padding(.trailing, 8) + + VStack(alignment: .leading) { + Text(sharedAccount.name) + Text(sharedAccount.host.absoluteString) + .foregroundStyle(.secondary) + .font(.subheadline) + } + + Spacer() + } + .padding() + } + .buttonStyle(PlainButtonStyle()) + .background(.ultraThickMaterial) + .clipShape(Capsule()) + .padding(.horizontal) + } + .navigationTitle(String(localized: "Accounts from other Apps", comment: "Navigation bar title")) +#if os(iOS) + .navigationBarTitleDisplayMode(.inline) +#endif + } + } + } + } +} + +#Preview { + + // swiftlint:disable force_unwrapping + let accounts = [ + SharedAccount("jane", on: URL(string: "http://localhost:8080")!, with: Image(systemName: "person.circle.fill")), + SharedAccount("john", on: URL(string: "http://localhost:8081")!, with: Image(systemName: "bird.circle.fill")), + SharedAccount("jean", on: URL(string: "http://localhost:8082")!, with: Image(systemName: "leaf.circle.fill")) + ] + + return SharedAccountsView(sharedAccounts: accounts) { _ in + print("Account selected!") + } +} diff --git a/Sources/NextcloudKitUI/Views/WebView.swift b/Sources/NextcloudKitUI/Views/WebView.swift new file mode 100644 index 00000000..4f3f4e5f --- /dev/null +++ b/Sources/NextcloudKitUI/Views/WebView.swift @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import WebKit + +/// +/// A generic web view which makes `WKWebView` available in SwiftUI which otherwise is not. +/// +/// This is the base block for presentation of web-based content. +/// Be it a live website in form of a login process or just a static HTML document about legal matters shipped with the app bundle. +/// +/// The web view is inspectable by Safari on connected developer machines for eased debugging. +/// +/// All of the web view session data is not persistent. +/// +public struct WebView: ViewRepresentable { + /// + /// This must be a binding to support presentation inside sheets. + /// The way this view gets created and updated inside a sheet has a race condition with sheet presentation. + /// A sheet might be displayed before a passed in state variable might have been set. + /// + @Binding var initialURL: URL? + + let userAgent: String? + + public init(initialURL: Binding, userAgent: String? = nil) { + self._initialURL = initialURL + self.userAgent = userAgent + } + + func makeView() -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + + let webView = WKWebView(frame: CGRect.zero, configuration: configuration) + webView.isInspectable = true + + if let userAgent { + webView.customUserAgent = userAgent + } + + return webView + } + + func updateView(_ webView: WKWebView, context: Context) { + + guard let initialURL = initialURL else { + return + } + + let request = URLRequest(url: initialURL) + webView.load(request) + } + + // MARK: - macOS + + #if os(macOS) + + public func makeNSView(context: Context) -> WKWebView { + makeView() + } + + public func updateNSView(_ webView: WKWebView, context: Context) { + updateView(webView, context: context) + } + + // MARK: - iOS + + #else + + public func makeUIView(context: Context) -> WKWebView { + makeView() + } + + public func updateUIView(_ webView: WKWebView, context: Context) { + updateView(webView, context: context) + } + + #endif +} + +#Preview { + // swiftlint:disable force_unwrapping + WebView(initialURL: .constant(URL(string: "http://localhost:8080")!), userAgent: nil) +} diff --git a/Tests/NextcloudKitUITests/QRCodeParsingTests.swift b/Tests/NextcloudKitUITests/QRCodeParsingTests.swift new file mode 100644 index 00000000..791b82f2 --- /dev/null +++ b/Tests/NextcloudKitUITests/QRCodeParsingTests.swift @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +@testable import NextcloudKitUI +import Testing + +/// +/// Test subject which conforms to ``QRCodeParsing``. +/// +struct QRCodeParsingTestSubject: QRCodeParsing {} + +/// +/// Tests for ``QRCodeParsing``. +/// +struct QRCodeParsingTests { + let testSubject: QRCodeParsingTestSubject + + init() { + testSubject = QRCodeParsingTestSubject() + } + + @Test func emptyCode() async throws { + #expect(throws: QRCodeError.implausibleLength) { + try testSubject.parse("") + } + } + + @Test func invalidPrefix() async throws { + #expect(throws: QRCodeError.invalidPrefix) { + try testSubject.parse("nope://login/&user:test&password:secret&server:http://localhost:8080") + } + } + + @Test func missingUser() async throws { + #expect(throws: QRCodeError.missingUser) { + try testSubject.parse("nc://login/&password:secret&server:http://localhost:8080") + } + } + + @Test func missingPassword() async throws { + #expect(throws: QRCodeError.missingPassword) { + try testSubject.parse("nc://login/&user:test&server:http://localhost:8080") + } + } + + @Test func missingHost() async throws { + #expect(throws: QRCodeError.missingHost) { + try testSubject.parse("nc://login/&user:test&password:secret") + } + } + + @Test func validCode() async throws { + #expect(throws: Never.self) { + try testSubject.parse("nc://login/&user:test&password:secret&server:http://localhost:8080") + } + } +} diff --git a/Tests/NextcloudKitUITests/URLSanitizingTests.swift b/Tests/NextcloudKitUITests/URLSanitizingTests.swift new file mode 100644 index 00000000..ca04a1b4 --- /dev/null +++ b/Tests/NextcloudKitUITests/URLSanitizingTests.swift @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +@testable import NextcloudKitUI +import Testing + +/// +/// Test subject which conforms to ``URLSanitizing``. +/// +struct URLSanitizingTestSubject: URLSanitizing {} + +/// +/// Tests for ``URLSanitizing``. +/// +struct URLSanitizingTests { + let testSubject: URLSanitizingTestSubject + + init() { + testSubject = URLSanitizingTestSubject() + } + + @Test func emptyString() async throws { + try #require(testSubject.sanitize("") == nil) + } + + @Test func invalidScheme() async throws { + try #require(testSubject.sanitize("ssh://www.nextcloud.com") == nil) + } + + @Test func validURL() async throws { + try #require(testSubject.sanitize("https://www.nextcloud.com") != nil) + } + + @Test func appendsMissingRootPath() async throws { + let sanitized = testSubject.sanitize("https://www.nextcloud.com")?.absoluteString + try #require(sanitized == "https://www.nextcloud.com/") + } + + @Test(arguments: ["https://www.nextcloud.com/nextcloud", "https://www.nextcloud.com/nextcloud/"]) func pathPrefix(_ pathPrefix: String) async throws { + let sanitized = try #require(testSubject.sanitize(pathPrefix)) + #expect(sanitized.absoluteString == "https://www.nextcloud.com/nextcloud/") + } + + @Test(arguments: ["https://www.nextcloud.com/index.php", "https://www.nextcloud.com/index.php/"]) func phpPathSuffix(_ pathPrefix: String) async throws { + let sanitized = try #require(testSubject.sanitize(pathPrefix)) + #expect(sanitized.absoluteString == "https://www.nextcloud.com/") + } +} From 5e7ab154e705f29637efaf4fb774ad66f23f2da1 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Thu, 28 May 2026 16:38:06 +0200 Subject: [PATCH 2/7] WIP Signed-off-by: Milen Pivchev --- Sources/NextcloudKitUI/Localizable.xcstrings | 129 +++++++++++++++ .../Views/{ => Login}/AccountButtonView.swift | 0 .../AlternativeLoginMethodsView.swift | 0 .../Views/{ => Login}/FormDetailView.swift | 0 .../Views/{ => Login}/ServerAddressView.swift | 0 .../{ => Login}/SharedAccountsView.swift | 0 .../Views/{ => Login}/WebView.swift | 0 .../Unified Share/UnifiedShareView.swift | 153 ++++++++++++++++++ .../Unified Share/UnifiedShareViewModel.swift | 18 +++ 9 files changed, 300 insertions(+) rename Sources/NextcloudKitUI/Views/{ => Login}/AccountButtonView.swift (100%) rename Sources/NextcloudKitUI/Views/{ => Login}/AlternativeLoginMethodsView.swift (100%) rename Sources/NextcloudKitUI/Views/{ => Login}/FormDetailView.swift (100%) rename Sources/NextcloudKitUI/Views/{ => Login}/ServerAddressView.swift (100%) rename Sources/NextcloudKitUI/Views/{ => Login}/SharedAccountsView.swift (100%) rename Sources/NextcloudKitUI/Views/{ => Login}/WebView.swift (100%) create mode 100644 Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift create mode 100644 Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareViewModel.swift diff --git a/Sources/NextcloudKitUI/Localizable.xcstrings b/Sources/NextcloudKitUI/Localizable.xcstrings index 101ae083..81291223 100644 --- a/Sources/NextcloudKitUI/Localizable.xcstrings +++ b/Sources/NextcloudKitUI/Localizable.xcstrings @@ -1,6 +1,135 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, + "Add people" : { + "comment" : "Prompt to add recipients in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add people" + } + } + } + }, + "Anyone" : { + "comment" : "Audience option for anyone with link.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anyone" + } + } + } + }, + "Can edit" : { + "comment" : "Label for a permission option that allows editing files.", + "isCommentAutoGenerated" : true + }, + "Can view" : { + "comment" : "Default permission shown in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Can view" + } + } + } + }, + "Copy link" : { + "comment" : "Button title for copying the share link.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy link" + } + } + } + }, + "Custom permissions" : { + "comment" : "Label for a permission option that allows custom permissions.", + "isCommentAutoGenerated" : true + }, + "File drop" : { + "comment" : "Text displayed in a notification when a file drop is available.", + "isCommentAutoGenerated" : true + }, + "Invited" : { + "comment" : "Audience option for invited people only.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invited" + } + } + } + }, + "Note to recipients" : { + "comment" : "Optional note field title in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Note to recipients" + } + } + } + }, + "Participants" : { + "comment" : "Label above the permission selector in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Participants" + } + } + } + }, + "Send" : { + "comment" : "Primary action button title in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send" + } + } + } + }, + "Settings" : { + "comment" : "Settings section title in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "Share Abc.txt" : { + "comment" : "Title in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share Abc.txt" + } + } + } + }, + "Test" : { + "comment" : "A row of settings options for a file share.", + "isCommentAutoGenerated" : true + }, "Accounts" : { "localizations" : { "en" : { diff --git a/Sources/NextcloudKitUI/Views/AccountButtonView.swift b/Sources/NextcloudKitUI/Views/Login/AccountButtonView.swift similarity index 100% rename from Sources/NextcloudKitUI/Views/AccountButtonView.swift rename to Sources/NextcloudKitUI/Views/Login/AccountButtonView.swift diff --git a/Sources/NextcloudKitUI/Views/AlternativeLoginMethodsView.swift b/Sources/NextcloudKitUI/Views/Login/AlternativeLoginMethodsView.swift similarity index 100% rename from Sources/NextcloudKitUI/Views/AlternativeLoginMethodsView.swift rename to Sources/NextcloudKitUI/Views/Login/AlternativeLoginMethodsView.swift diff --git a/Sources/NextcloudKitUI/Views/FormDetailView.swift b/Sources/NextcloudKitUI/Views/Login/FormDetailView.swift similarity index 100% rename from Sources/NextcloudKitUI/Views/FormDetailView.swift rename to Sources/NextcloudKitUI/Views/Login/FormDetailView.swift diff --git a/Sources/NextcloudKitUI/Views/ServerAddressView.swift b/Sources/NextcloudKitUI/Views/Login/ServerAddressView.swift similarity index 100% rename from Sources/NextcloudKitUI/Views/ServerAddressView.swift rename to Sources/NextcloudKitUI/Views/Login/ServerAddressView.swift diff --git a/Sources/NextcloudKitUI/Views/SharedAccountsView.swift b/Sources/NextcloudKitUI/Views/Login/SharedAccountsView.swift similarity index 100% rename from Sources/NextcloudKitUI/Views/SharedAccountsView.swift rename to Sources/NextcloudKitUI/Views/Login/SharedAccountsView.swift diff --git a/Sources/NextcloudKitUI/Views/WebView.swift b/Sources/NextcloudKitUI/Views/Login/WebView.swift similarity index 100% rename from Sources/NextcloudKitUI/Views/WebView.swift rename to Sources/NextcloudKitUI/Views/Login/WebView.swift diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift new file mode 100644 index 00000000..ab82a06f --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +/// View used for Unified Sharing. +public struct UnifiedShareView: View { + @State private var selectedAudience: ShareeType = .invited + @State private var permission: Permission = .canView + @State private var isSettingsExpanded = true + @State private var recipients = "" + @State private var note = "" + + public init() { } + + public var body: some View { + ZStack { + Color.black.opacity(0.18) + .ignoresSafeArea() + + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 24) { + Text(String(localized: "Share Abc.txt")) + .font(.system(size: 48 / 2, weight: .medium)) + .foregroundStyle(.primary) + + audiencePicker + + VStack(spacing: 18) { + TextField( + String(localized: "Add people"), + text: $recipients + ) + .textFieldStyle(.roundedBorder) + + permissionField + settingsRow + + TextField( + String(localized: "Note to recipients"), + text: $note, + axis: .vertical + ) + .textFieldStyle(.roundedBorder) + } + + actionButtons + } + .padding(.horizontal, 26) + .padding(.top, 22) + .padding(.bottom, 26) + } + .background( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill(.background.secondary) + ) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(.separator.opacity(0.2), lineWidth: 0.5) + ) + .shadow(color: .black.opacity(0.12), radius: 24, y: 8) + .padding(.horizontal, 18) + .padding(.vertical, 10) + } + } + + private var audiencePicker: some View { + Picker(String(localized: ""), selection: $selectedAudience) { + Label( + String(localized: "Invited"), + systemImage: "checkmark" + ) + .tag(ShareeType.invited) + + Text(String(localized: "Anyone")) + .tag(ShareeType.anyone) + } + .pickerStyle(.segmented) + } + + private var permissionField: some View { + LabeledContent(String(localized: "Participants")) { + Picker(String(localized: "Participants"), selection: $permission) { + ForEach(Permission.allCases) { permission in + Text(permission.localizedTitle) + .tag(permission) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + } + + private var settingsRow: some View { + DisclosureGroup(isExpanded: $isSettingsExpanded) { + Text("Test") + Text("Test") + } label: { + Text(String(localized: "Settings")) + } + } + + private var actionButtons: some View { + HStack(spacing: 16) { + Button(String(localized: "Copy link")) { + } + .buttonStyle(.bordered) + .frame(maxWidth: .infinity) + + Button(String(localized: "Send")) { + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + } + .padding(.top, 18) + } +} + +private extension UnifiedShareView { + enum ShareeType { + case invited + case anyone + } + + enum Permission: CaseIterable, Identifiable { + case canView + case canEdit + case fileDrop + case customPermissions + + var id: Self { + self + } + + var localizedTitle: String { + switch self { + case .canView: + String(localized: "Can view") + case .canEdit: + String(localized: "Can edit") + case .fileDrop: + String(localized: "File drop") + case .customPermissions: + String(localized: "Custom permissions") + } + } + } +} + +#Preview { + UnifiedShareView() +} diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareViewModel.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareViewModel.swift new file mode 100644 index 00000000..fcf83129 --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareViewModel.swift @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// +/// View model for ``UnifiedShareView``. +/// +@MainActor +public final class UnifiedShareViewModel { + /// + /// Create a unified share view model. + /// + public init() { + // No state yet. + } +} From b173ff404524ee19de3dc8bf1dd0bdc3ba91286e Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 29 May 2026 18:41:37 +0200 Subject: [PATCH 3/7] WIP Signed-off-by: Milen Pivchev --- .../NextcloudKit/Models/NKOCSWrapper.swift | 23 + .../Models/NKTermsOfService.swift | 29 +- .../UnifiedSharing/NKUnifiedShare+Mock.swift | 90 ++++ .../UnifiedSharing/NKUnifiedShare.swift | 101 ++++ .../UnifiedSharing/NKUnifiedShareIcon.swift | 21 + .../UnifiedSharing/NKUnifiedShareOwner.swift | 20 + .../NKUnifiedSharePermission.swift | 22 + .../NKUnifiedShareProperty.swift | 180 +++++++ .../NKUnifiedShareRecipient.swift | 22 + .../UnifiedSharing/NKUnifiedShareSource.swift | 20 + .../UnifiedSharing/NKUnifiedShareState.swift | 12 + .../NextcloudKit+TermsOfService.swift | 2 +- .../NextcloudKit+UnifiedSharing.swift | 448 ++++++++++++++++++ .../Unified Share/UnifiedShareView.swift | 97 ++-- .../Unified Share/UnifiedShareViewModel.swift | 57 ++- .../UnifiedShareDecodingTests.swift | 182 +++++++ 16 files changed, 1250 insertions(+), 76 deletions(-) create mode 100644 Sources/NextcloudKit/Models/NKOCSWrapper.swift create mode 100644 Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare+Mock.swift create mode 100644 Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare.swift create mode 100644 Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareIcon.swift create mode 100644 Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareOwner.swift create mode 100644 Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermission.swift create mode 100644 Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareProperty.swift create mode 100644 Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient.swift create mode 100644 Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareSource.swift create mode 100644 Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareState.swift create mode 100644 Sources/NextcloudKit/NextcloudKit+UnifiedSharing.swift create mode 100644 Tests/NextcloudKitUnitTests/UnifiedShareDecodingTests.swift diff --git a/Sources/NextcloudKit/Models/NKOCSWrapper.swift b/Sources/NextcloudKit/Models/NKOCSWrapper.swift new file mode 100644 index 00000000..8cfc1004 --- /dev/null +++ b/Sources/NextcloudKit/Models/NKOCSWrapper.swift @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Generic `Decodable` envelope for OCS v2 responses. +/// Server replies follow `{ "ocs": { "meta": ..., "data": ... } }`. +public struct NKOCSWrapper: Decodable { + public let ocs: Inner + + public struct Inner: Decodable { + public let meta: NKOCSMeta + public let data: T + } +} + +/// OCS response metadata. `message` is optional per the OCS contract. +public struct NKOCSMeta: Decodable, Sendable { + public let status: String + public let statuscode: Int + public let message: String? +} diff --git a/Sources/NextcloudKit/Models/NKTermsOfService.swift b/Sources/NextcloudKit/Models/NKTermsOfService.swift index 61c17d77..05c57d7a 100644 --- a/Sources/NextcloudKit/Models/NKTermsOfService.swift +++ b/Sources/NextcloudKit/Models/NKTermsOfService.swift @@ -5,7 +5,10 @@ import Foundation public class NKTermsOfService: NSObject { - public var meta: Meta? + /// Source-compat alias for callers that still reference `NKTermsOfService.Meta`. + public typealias Meta = NKOCSMeta + + public var meta: NKOCSMeta? public var data: OCSData? public override init() { @@ -14,9 +17,9 @@ public class NKTermsOfService: NSObject { public func loadFromJSON(_ jsonData: Data) -> Bool { do { - let decodedResponse = try JSONDecoder().decode(OCSResponse.self, from: jsonData) - self.meta = decodedResponse.ocs.meta - self.data = decodedResponse.ocs.data + let decoded = try JSONDecoder().decode(NKOCSWrapper.self, from: jsonData) + self.meta = decoded.ocs.meta + self.data = decoded.ocs.data return true } catch { debugPrint("[DEBUG] decode error:", error) @@ -36,26 +39,10 @@ public class NKTermsOfService: NSObject { return data?.hasSigned ?? false } - public func getMeta() -> Meta? { + public func getMeta() -> NKOCSMeta? { return meta } - // MARK: - Codable - private class OCSResponse: Codable { - let ocs: OCS - } - - private class OCS: Codable { - let meta: Meta - let data: OCSData - } - - public class Meta: Codable { - public let status: String - public let statuscode: Int - public let message: String - } - public class OCSData: Codable { public let terms: [Term] public let languages: [String: String] diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare+Mock.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare+Mock.swift new file mode 100644 index 00000000..d09c972b --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare+Mock.swift @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +#if DEBUG +import Foundation + +public extension NKUnifiedShare { + /// Sample share for SwiftUI previews and tests, covering all property variants. + static var mock: NKUnifiedShare { + NKUnifiedShare( + id: "preview-1", + owner: NKUnifiedShareOwner( + userId: "alice", + instance: nil, + displayName: "Alice", + icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil) + ), + lastUpdated: 1_730_000_000_000, + state: .draft, + sources: [ + NKUnifiedShareSource( + class: "file", + value: "/Test.txt", + displayName: "Test.txt", + icon: NKUnifiedShareIcon(svg: nil, light: "https://example.com/light.png", dark: "https://example.com/dark.png") + ) + ], + recipients: [ + NKUnifiedShareRecipient( + class: "user", + value: "bob", + instance: nil, + displayName: "Bob", + icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil) + ) + ], + properties: [ + NKUnifiedSharePropertyDate( + class: "expiration", + displayName: "Expiration", + priority: 10, + required: false, + minDate: "2026-01-01" + ), + NKUnifiedSharePropertyEnum( + class: "role", + displayName: "Role", + priority: 20, + required: true, + value: "editor", + validValues: ["viewer", "editor"] + ), + NKUnifiedSharePropertyBoolean( + class: "download", + displayName: "Allow download", + priority: 30, + required: false, + value: "true" + ), + NKUnifiedSharePropertyPassword( + class: "password", + displayName: "Password", + hint: "Min 8 chars", + priority: 40, + required: false + ), + NKUnifiedSharePropertyString( + class: "note", + displayName: "Note", + priority: 50, + required: false, + value: "hi", + minLength: 0, + maxLength: 1000 + ) + ], + permissions: [ + NKUnifiedSharePermission( + class: "download", + displayName: "Allow download", + hint: nil, + category: nil, + enabled: true + ) + ] + ) + } +} +#endif diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare.swift new file mode 100644 index 00000000..78c24be5 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare.swift @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// A unified share returned by `ocs/v2.php/apps/sharing/api/v1/...`. +public final class NKUnifiedShare: Codable { + public let id: String + public let owner: NKUnifiedShareOwner + /// Unix time in milliseconds. + public let lastUpdated: Int64 + public let state: NKUnifiedShareState + public let sources: [NKUnifiedShareSource] + public let recipients: [NKUnifiedShareRecipient] + public let properties: [NKUnifiedShareProperty] + public let permissions: [NKUnifiedSharePermission] + + enum CodingKeys: String, CodingKey { + case id + case owner + case lastUpdated = "last_updated" + case state + case sources + case recipients + case properties + case permissions + } + + public init(id: String, + owner: NKUnifiedShareOwner, + lastUpdated: Int64, + state: NKUnifiedShareState, + sources: [NKUnifiedShareSource], + recipients: [NKUnifiedShareRecipient], + properties: [NKUnifiedShareProperty], + permissions: [NKUnifiedSharePermission]) { + self.id = id + self.owner = owner + self.lastUpdated = lastUpdated + self.state = state + self.sources = sources + self.recipients = recipients + self.properties = properties + self.permissions = permissions + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.id = try c.decode(String.self, forKey: .id) + self.owner = try c.decode(NKUnifiedShareOwner.self, forKey: .owner) + self.lastUpdated = try c.decode(Int64.self, forKey: .lastUpdated) + self.state = try c.decode(NKUnifiedShareState.self, forKey: .state) + self.sources = try c.decode([NKUnifiedShareSource].self, forKey: .sources) + self.recipients = try c.decode([NKUnifiedShareRecipient].self, forKey: .recipients) + self.permissions = try c.decode([NKUnifiedSharePermission].self, forKey: .permissions) + self.properties = try Self.decodeProperties(from: c) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(owner, forKey: .owner) + try c.encode(lastUpdated, forKey: .lastUpdated) + try c.encode(state, forKey: .state) + try c.encode(sources, forKey: .sources) + try c.encode(recipients, forKey: .recipients) + try c.encode(permissions, forKey: .permissions) + try c.encode(properties, forKey: .properties) + } + + /// Dispatch each `properties` element to the correct subclass based on its `type` discriminator. + /// Uses two independent unkeyed containers — one to peek, one to decode — so we can read the + /// `type` of every element without consuming the cursor we actually need for the concrete decode. + private static func decodeProperties(from c: KeyedDecodingContainer) throws -> [NKUnifiedShareProperty] { + var peek = try c.nestedUnkeyedContainer(forKey: .properties) + var real = try c.nestedUnkeyedContainer(forKey: .properties) + var result: [NKUnifiedShareProperty] = [] + + while !real.isAtEnd { + let holder = try peek.decode(TypeHolder.self) + switch holder.type { + case .date: + result.append(try real.decode(NKUnifiedSharePropertyDate.self)) + case .enumeration: + result.append(try real.decode(NKUnifiedSharePropertyEnum.self)) + case .boolean: + result.append(try real.decode(NKUnifiedSharePropertyBoolean.self)) + case .password: + result.append(try real.decode(NKUnifiedSharePropertyPassword.self)) + case .string: + result.append(try real.decode(NKUnifiedSharePropertyString.self)) + } + } + return result + } + + private struct TypeHolder: Decodable { + let type: NKUnifiedSharePropertyType + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareIcon.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareIcon.swift new file mode 100644 index 00000000..3a97304b --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareIcon.swift @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Icon attached to an owner, source, recipient, or permission category. +/// +/// The OpenAPI declares this as `anyOf `. The two variants have disjoint key +/// sets (`svg` vs `light`+`dark`) so a single flat struct with all keys optional decodes either +/// shape cleanly with synthesized Codable. +public struct NKUnifiedShareIcon: Codable, Sendable { + /// Inline SVG body (IconSVG variant). + public let svg: String? + + /// Absolute URL to a light-theme image (IconURL variant). + public let light: String? + + /// Absolute URL to a dark-theme image (IconURL variant). + public let dark: String? +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareOwner.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareOwner.swift new file mode 100644 index 00000000..b81f7846 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareOwner.swift @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Owner of a unified share. +public struct NKUnifiedShareOwner: Codable, Sendable { + public let userId: String + public let instance: String? + public let displayName: String + public let icon: NKUnifiedShareIcon + + enum CodingKeys: String, CodingKey { + case userId = "user_id" + case instance + case displayName = "display_name" + case icon + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermission.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermission.swift new file mode 100644 index 00000000..c1e209ba --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermission.swift @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// A toggleable permission on a unified share (can view / can edit / can comment / …). +public struct NKUnifiedSharePermission: Codable, Sendable { + public let `class`: String + public let displayName: String + public let hint: String? + public let category: String? + public let enabled: Bool + + enum CodingKeys: String, CodingKey { + case `class` + case displayName = "display_name" + case hint + case category + case enabled + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareProperty.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareProperty.swift new file mode 100644 index 00000000..eb1baac9 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareProperty.swift @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Property type discriminator. +public enum NKUnifiedSharePropertyType: String, Codable, Sendable { + case date + case enumeration = "enum" + case boolean + case password + case string +} + +/// Base class for a unified share property. +/// +/// Variant fields live on the concrete subclasses (`NKUnifiedSharePropertyDate`, …). When decoding +/// `NKUnifiedShare.properties`, the dispatch on `type` picks the right subclass. +public class NKUnifiedShareProperty: Codable { + public let `class`: String + public let displayName: String + public let hint: String? + public let priority: Int + public let required: Bool + public let value: String? + public let type: NKUnifiedSharePropertyType + + enum CodingKeys: String, CodingKey { + case `class` + case displayName = "display_name" + case hint + case priority + case required + case value + case type + } + + public init(class: String, displayName: String, hint: String?, priority: Int, required: Bool, value: String?, type: NKUnifiedSharePropertyType) { + self.class = `class` + self.displayName = displayName + self.hint = hint + self.priority = priority + self.required = required + self.value = value + self.type = type + } + + public required init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.class = try c.decode(String.self, forKey: .class) + self.displayName = try c.decode(String.self, forKey: .displayName) + self.hint = try c.decodeIfPresent(String.self, forKey: .hint) + self.priority = try c.decode(Int.self, forKey: .priority) + self.required = try c.decode(Bool.self, forKey: .required) + self.value = try c.decodeIfPresent(String.self, forKey: .value) + self.type = try c.decode(NKUnifiedSharePropertyType.self, forKey: .type) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(self.class, forKey: .class) + try c.encode(displayName, forKey: .displayName) + try c.encodeIfPresent(hint, forKey: .hint) + try c.encode(priority, forKey: .priority) + try c.encode(required, forKey: .required) + try c.encodeIfPresent(value, forKey: .value) + try c.encode(type, forKey: .type) + } +} + +/// Date-typed property; adds an optional valid range. +public final class NKUnifiedSharePropertyDate: NKUnifiedShareProperty { + public let minDate: String? + public let maxDate: String? + + enum DateKeys: String, CodingKey { + case minDate = "min_date" + case maxDate = "max_date" + } + + public init(class: String, displayName: String, hint: String? = nil, priority: Int, required: Bool, value: String? = nil, minDate: String? = nil, maxDate: String? = nil) { + self.minDate = minDate + self.maxDate = maxDate + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, value: value, type: .date) + } + + public required init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: DateKeys.self) + self.minDate = try c.decodeIfPresent(String.self, forKey: .minDate) + self.maxDate = try c.decodeIfPresent(String.self, forKey: .maxDate) + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + var c = encoder.container(keyedBy: DateKeys.self) + try c.encodeIfPresent(minDate, forKey: .minDate) + try c.encodeIfPresent(maxDate, forKey: .maxDate) + } +} + +/// Enum-typed property; carries the allowed value set. +public final class NKUnifiedSharePropertyEnum: NKUnifiedShareProperty { + public let validValues: [String] + + enum EnumKeys: String, CodingKey { + case validValues = "valid_values" + } + + public init(class: String, displayName: String, hint: String? = nil, priority: Int, required: Bool, value: String? = nil, validValues: [String]) { + self.validValues = validValues + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, value: value, type: .enumeration) + } + + public required init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: EnumKeys.self) + self.validValues = try c.decode([String].self, forKey: .validValues) + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + var c = encoder.container(keyedBy: EnumKeys.self) + try c.encode(validValues, forKey: .validValues) + } +} + +/// Boolean-typed property; no additional fields. +public final class NKUnifiedSharePropertyBoolean: NKUnifiedShareProperty { + public init(class: String, displayName: String, hint: String? = nil, priority: Int, required: Bool, value: String? = nil) { + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, value: value, type: .boolean) + } + + public required init(from decoder: Decoder) throws { + try super.init(from: decoder) + } +} + +/// Password-typed property; no additional fields. +public final class NKUnifiedSharePropertyPassword: NKUnifiedShareProperty { + public init(class: String, displayName: String, hint: String? = nil, priority: Int, required: Bool, value: String? = nil) { + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, value: value, type: .password) + } + + public required init(from decoder: Decoder) throws { + try super.init(from: decoder) + } +} + +/// String-typed property; adds optional length bounds. +public final class NKUnifiedSharePropertyString: NKUnifiedShareProperty { + public let minLength: Int? + public let maxLength: Int? + + enum StringKeys: String, CodingKey { + case minLength = "min_length" + case maxLength = "max_length" + } + + public init(class: String, displayName: String, hint: String? = nil, priority: Int, required: Bool, value: String? = nil, minLength: Int? = nil, maxLength: Int? = nil) { + self.minLength = minLength + self.maxLength = maxLength + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, value: value, type: .string) + } + + public required init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: StringKeys.self) + self.minLength = try c.decodeIfPresent(Int.self, forKey: .minLength) + self.maxLength = try c.decodeIfPresent(Int.self, forKey: .maxLength) + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + var c = encoder.container(keyedBy: StringKeys.self) + try c.encodeIfPresent(minLength, forKey: .minLength) + try c.encodeIfPresent(maxLength, forKey: .maxLength) + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient.swift new file mode 100644 index 00000000..3f36b478 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient.swift @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// A recipient (user, group, federated user, public link, …) on a unified share. +public struct NKUnifiedShareRecipient: Codable, Sendable { + public let `class`: String + public let value: String + public let instance: String? + public let displayName: String + public let icon: NKUnifiedShareIcon? + + enum CodingKeys: String, CodingKey { + case `class` + case value + case instance + case displayName = "display_name" + case icon + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareSource.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareSource.swift new file mode 100644 index 00000000..702f4ec2 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareSource.swift @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// An item being shared (file, folder, calendar, contact, …). +public struct NKUnifiedShareSource: Codable, Sendable { + public let `class`: String + public let value: String + public let displayName: String + public let icon: NKUnifiedShareIcon? + + enum CodingKeys: String, CodingKey { + case `class` + case value + case displayName = "display_name" + case icon + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareState.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareState.swift new file mode 100644 index 00000000..7cfd5a35 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareState.swift @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Lifecycle state of a unified share. +public enum NKUnifiedShareState: String, Codable, Sendable { + case active + case draft + case deleted +} diff --git a/Sources/NextcloudKit/NextcloudKit+TermsOfService.swift b/Sources/NextcloudKit/NextcloudKit+TermsOfService.swift index a3b88929..dd2f9cf8 100644 --- a/Sources/NextcloudKit/NextcloudKit+TermsOfService.swift +++ b/Sources/NextcloudKit/NextcloudKit+TermsOfService.swift @@ -34,7 +34,7 @@ public extension NextcloudKit { if meta.statuscode == 200 { options.queue.async { completion(account, tos, response, .success) } } else { - options.queue.async { completion(account, tos, response, NKError(errorCode: meta.statuscode, errorDescription: meta.message, responseData: jsonData)) } + options.queue.async { completion(account, tos, response, NKError(errorCode: meta.statuscode, errorDescription: meta.message ?? "", responseData: jsonData)) } } } else { options.queue.async { completion(account, nil, response, .invalidData) } diff --git a/Sources/NextcloudKit/NextcloudKit+UnifiedSharing.swift b/Sources/NextcloudKit/NextcloudKit+UnifiedSharing.swift new file mode 100644 index 00000000..4fdd7ddb --- /dev/null +++ b/Sources/NextcloudKit/NextcloudKit+UnifiedSharing.swift @@ -0,0 +1,448 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Alamofire + +/// Endpoints for the unified-sharing OCS API (`ocs/v2.php/apps/sharing/api/v1/...`). +public extension NextcloudKit { + // MARK: - List & search + + /// `GET /shares` — paginated list of shares the current user can see. + func listUnifiedShares(sourceClass: String? = nil, + lastShareID: String? = nil, + limit: Int? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, shares: [NKUnifiedShare]?, responseData: AFDataResponse?, error: NKError) { + let endpoint = "ocs/v2.php/apps/sharing/api/v1/shares" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, nil, .urlError) + } + + var parameters: [String: String] = [:] + if let sourceClass { parameters["sourceClass"] = sourceClass } + if let lastShareID { parameters["lastShareID"] = lastShareID } + if let limit { parameters["limit"] = String(limit) } + + let response = await nkSession.sessionData + .request(url, method: .get, parameters: parameters, encoding: URLEncoding.default, + headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + return decodeUnifiedShareList(response: response, account: account) + } + + /// `GET /recipients` — search recipients (users, groups, federated …) by free-text query. + func searchUnifiedShareRecipients(query: String, + recipientTypeClass: String? = nil, + limit: Int? = nil, + offset: Int? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, recipients: [NKUnifiedShareRecipient]?, responseData: AFDataResponse?, error: NKError) { + let endpoint = "ocs/v2.php/apps/sharing/api/v1/recipients" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, nil, .urlError) + } + + var parameters: [String: String] = ["query": query] + if let recipientTypeClass { parameters["recipientTypeClass"] = recipientTypeClass } + if let limit { parameters["limit"] = String(limit) } + if let offset { parameters["offset"] = String(offset) } + + let response = await nkSession.sessionData + .request(url, method: .get, parameters: parameters, encoding: URLEncoding.default, + headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + switch response.result { + case .failure(let error): + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) + case .success(let data): + do { + let wrap = try JSONDecoder().decode(NKOCSWrapper<[NKUnifiedShareRecipient]>.self, from: data) + guard 200..<300 ~= wrap.ocs.meta.statuscode else { + return (account, nil, response, NKError(statusCode: wrap.ocs.meta.statuscode, fallbackDescription: wrap.ocs.meta.message ?? "", responseData: data)) + } + return (account, wrap.ocs.data, response, .success) + } catch { + return (account, nil, response, NKError(error: error, responseData: data)) + } + } + } + + // MARK: - Single share lifecycle + + /// `POST /share` — create a new (draft) share. Returns the created share. + func createUnifiedShare(account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + let endpoint = "ocs/v2.php/apps/sharing/api/v1/share" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, nil, .urlError) + } + + let response = await nkSession.sessionData + .request(url, method: .post, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + return decodeUnifiedShare(response: response, account: account) + } + + /// `POST /share/{id}` — fetch a specific share. Modelled as POST because the body carries + /// `secret` and free-form `arguments` per the OpenAPI. + func getUnifiedShare(id: String, + secret: String? = nil, + arguments: [String: Any]? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + guard let encodedId = id.urlEncoded else { + return (account, nil, nil, .urlError) + } + let endpoint = "ocs/v2.php/apps/sharing/api/v1/share/\(encodedId)" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, nil, .urlError) + } + + var body: [String: Any] = [:] + if let secret { body["secret"] = secret } + if let arguments { body["arguments"] = arguments } + + var urlRequest: URLRequest + + do { + urlRequest = try URLRequest(url: url, method: .post, headers: headers) + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if !body.isEmpty { + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + } + } catch { + return (account, nil, nil, NKError(error: error)) + } + + let response = await nkSession.sessionData + .request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + return decodeUnifiedShare(response: response, account: account) + } + + /// `DELETE /share/{id}` — 204 success, no response body. + func deleteUnifiedShare(id: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, responseData: AFDataResponse?, error: NKError) { + guard let encodedId = id.urlEncoded else { + return (account, nil, .urlError) + } + let endpoint = "ocs/v2.php/apps/sharing/api/v1/share/\(encodedId)" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, .urlError) + } + + let response = await nkSession.sessionData + .request(url, method: .delete, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + switch response.result { + case .failure(let error): + return (account, response, NKError(error: error, afResponse: response, responseData: response.data)) + case .success: + return (account, response, .success) + } + } + + // MARK: - Share mutations (return the updated share) + + /// `PUT /share/{id}/enabled` — toggle a permission. + func setUnifiedSharePermissionEnabled(id: String, + permissionClass: String, + enabled: Bool, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + await mutateUnifiedShare(method: .put, + subpath: "enabled", + id: id, + body: ["class": permissionClass, "enabled": enabled], + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `PUT /share/{id}/property` — set a property's value. + func setUnifiedShareProperty(id: String, + propertyClass: String, + value: String?, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + var body: [String: Any] = ["class": propertyClass] + if let value { body["value"] = value } + return await mutateUnifiedShare(method: .put, + subpath: "property", + id: id, + body: body, + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `PUT /share/{id}/state` — transition state (active/draft/deleted). + func setUnifiedShareState(id: String, + state: NKUnifiedShareState, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + await mutateUnifiedShare(method: .put, + subpath: "state", + id: id, + body: ["state": state.rawValue], + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `POST /share/{id}/recipient` — add a recipient. + func addUnifiedShareRecipient(id: String, + recipientClass: String, + value: String, + instance: String? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + var body: [String: Any] = ["class": recipientClass, "value": value] + if let instance { body["instance"] = instance } + return await mutateUnifiedShare(method: .post, + subpath: "recipient", + id: id, + body: body, + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `DELETE /share/{id}/recipient` — remove a recipient. Parameters travel as query string. + func removeUnifiedShareRecipient(id: String, + recipientClass: String, + value: String, + instance: String? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + var query: [String: String] = ["class": recipientClass, "value": value] + if let instance { query["instance"] = instance } + return await mutateUnifiedShareWithQuery(method: .delete, + subpath: "recipient", + id: id, + query: query, + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `POST /share/{id}/source` — add a source. + func addUnifiedShareSource(id: String, + sourceClass: String, + value: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + await mutateUnifiedShare(method: .post, + subpath: "source", + id: id, + body: ["class": sourceClass, "value": value], + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `DELETE /share/{id}/source` — remove a source. Parameters travel as query string. + func removeUnifiedShareSource(id: String, + sourceClass: String, + value: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + await mutateUnifiedShareWithQuery(method: .delete, + subpath: "source", + id: id, + query: ["class": sourceClass, "value": value], + account: account, + options: options, + taskHandler: taskHandler) + } + + // MARK: - Private helpers + + /// Shared body-carrying mutation that returns the updated share. + private func mutateUnifiedShare(method: HTTPMethod, + subpath: String, + id: String, + body: [String: Any], + account: String, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + guard let encodedId = id.urlEncoded else { + return (account, nil, nil, .urlError) + } + let endpoint = "ocs/v2.php/apps/sharing/api/v1/share/\(encodedId)/\(subpath)" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, nil, .urlError) + } + + var urlRequest: URLRequest + + do { + urlRequest = try URLRequest(url: url, method: method, headers: headers) + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + } catch { + return (account, nil, nil, NKError(error: error)) + } + + let response = await nkSession.sessionData + .request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + return decodeUnifiedShare(response: response, account: account) + } + + /// Shared query-carrying mutation (DELETE subresources) that returns the updated share. + private func mutateUnifiedShareWithQuery(method: HTTPMethod, + subpath: String, + id: String, + query: [String: String], + account: String, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + guard let encodedId = id.urlEncoded else { + return (account, nil, nil, .urlError) + } + let endpoint = "ocs/v2.php/apps/sharing/api/v1/share/\(encodedId)/\(subpath)" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, nil, .urlError) + } + + let response = await nkSession.sessionData + .request(url, method: method, parameters: query, encoding: URLEncoding.queryString, + headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + return decodeUnifiedShare(response: response, account: account) + } + + /// Decode an OCS response containing a single `Share`. + private func decodeUnifiedShare(response: AFDataResponse, + account: String + ) -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + switch response.result { + case .failure(let error): + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) + case .success(let data): + do { + let wrap = try JSONDecoder().decode(NKOCSWrapper.self, from: data) + guard 200..<300 ~= wrap.ocs.meta.statuscode else { + return (account, nil, response, NKError(statusCode: wrap.ocs.meta.statuscode, fallbackDescription: wrap.ocs.meta.message ?? "", responseData: data)) + } + return (account, wrap.ocs.data, response, .success) + } catch { + return (account, nil, response, NKError(error: error, responseData: data)) + } + } + } + + /// Decode an OCS response containing an array of `Share`. + private func decodeUnifiedShareList(response: AFDataResponse, + account: String + ) -> (account: String, shares: [NKUnifiedShare]?, responseData: AFDataResponse?, error: NKError) { + switch response.result { + case .failure(let error): + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) + case .success(let data): + do { + let wrap = try JSONDecoder().decode(NKOCSWrapper<[NKUnifiedShare]>.self, from: data) + guard 200..<300 ~= wrap.ocs.meta.statuscode else { + return (account, nil, response, NKError(statusCode: wrap.ocs.meta.statuscode, fallbackDescription: wrap.ocs.meta.message ?? "", responseData: data)) + } + return (account, wrap.ocs.data, response, .success) + } catch { + return (account, nil, response, NKError(error: error, responseData: data)) + } + } + } +} diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift index ab82a06f..4a2ae723 100644 --- a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift @@ -3,75 +3,80 @@ // SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI +import NextcloudKit /// View used for Unified Sharing. public struct UnifiedShareView: View { - @State private var selectedAudience: ShareeType = .invited + let fileName: String + let account: String + @State private var model: UnifiedShareViewModel + + @State private var shareeType: ShareeType = .invited @State private var permission: Permission = .canView @State private var isSettingsExpanded = true @State private var recipients = "" @State private var note = "" - public init() { } + public init(fileName: String, account: String) { + self.fileName = fileName + self.account = account + model = UnifiedShareViewModel(account: account) + } - public var body: some View { - ZStack { - Color.black.opacity(0.18) - .ignoresSafeArea() + init(fileName: String, model: UnifiedShareViewModel) { + self.fileName = fileName + self.account = model.account + self.model = model + } - VStack(spacing: 0) { - VStack(alignment: .leading, spacing: 24) { - Text(String(localized: "Share Abc.txt")) - .font(.system(size: 48 / 2, weight: .medium)) - .foregroundStyle(.primary) + public var body: some View { + VStack(alignment: .leading, spacing: 24) { + switch model.state { + case .loading: + ProgressView() + case .shareUpdated(let share): + Text(String(localized: "Share \(fileName)")) + .font(.title) + // .foregroundStyle(.primary) audiencePicker - VStack(spacing: 18) { + // VStack(spacing: 18) { + if shareeType == .invited { TextField( String(localized: "Add people"), text: $recipients ) .textFieldStyle(.roundedBorder) + } - permissionField - settingsRow + permissionField + settingsRow - TextField( - String(localized: "Note to recipients"), - text: $note, - axis: .vertical - ) - .textFieldStyle(.roundedBorder) - } + TextField( + String(localized: "Note to recipients"), + text: $note, + axis: .vertical + ) + .textFieldStyle(.roundedBorder) actionButtons - } - .padding(.horizontal, 26) - .padding(.top, 22) - .padding(.bottom, 26) + + case .error(let error): + Text("Error") } - .background( - RoundedRectangle(cornerRadius: 28, style: .continuous) - .fill(.background.secondary) - ) - .overlay( - RoundedRectangle(cornerRadius: 28, style: .continuous) - .stroke(.separator.opacity(0.2), lineWidth: 0.5) - ) - .shadow(color: .black.opacity(0.12), radius: 24, y: 8) - .padding(.horizontal, 18) - .padding(.vertical, 10) + + // } + } + .padding(.horizontal, 26) + .padding(.top, 10) } private var audiencePicker: some View { - Picker(String(localized: ""), selection: $selectedAudience) { - Label( - String(localized: "Invited"), - systemImage: "checkmark" - ) - .tag(ShareeType.invited) + Picker("", selection: $shareeType) { + Text(String(localized: "Invited")) + .tag(ShareeType.invited) Text(String(localized: "Anyone")) .tag(ShareeType.anyone) @@ -80,14 +85,13 @@ public struct UnifiedShareView: View { } private var permissionField: some View { - LabeledContent(String(localized: "Participants")) { + LabeledContent(String(localized: shareeType == .anyone ? "Anyone with the link" : "Participants")) { Picker(String(localized: "Participants"), selection: $permission) { ForEach(Permission.allCases) { permission in Text(permission.localizedTitle) .tag(permission) } } - .labelsHidden() .pickerStyle(.menu) } } @@ -149,5 +153,8 @@ private extension UnifiedShareView { } #Preview { - UnifiedShareView() + UnifiedShareView( + fileName: "Test.txt", + model: UnifiedShareViewModel(account: "", state: .shareUpdated(share: .mock)) + ) } diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareViewModel.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareViewModel.swift index fcf83129..e47514f1 100644 --- a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareViewModel.swift +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareViewModel.swift @@ -3,16 +3,55 @@ // SPDX-License-Identifier: GPL-3.0-or-later import Foundation +import NextcloudKit + +enum UnifiedShareViewState { + case loading + case shareUpdated(share: NKUnifiedShare) + case recipientsUpdated(recipient: [NKUnifiedShareRecipient]) + case error(Error) +} -/// -/// View model for ``UnifiedShareView``. -/// @MainActor -public final class UnifiedShareViewModel { - /// - /// Create a unified share view model. - /// - public init() { - // No state yet. +@Observable +public class UnifiedShareViewModel { + var state: UnifiedShareViewState = .loading + let account: String + + init(account: String) { + self.account = account + } + +#if DEBUG + /// Preview-only initializer that starts in a given state. + init(account: String, state: UnifiedShareViewState) { + self.account = account + self.state = state + } +#endif + + func createShare() { + Task { + let result = await NextcloudKit.shared.createUnifiedShare(account: account) + guard let share = result.share else { + state = .error(result.error) + return + } + + state = .shareUpdated(share: share) + } + } + + func searchRecipients(query: String) { + Task { + let result = await NextcloudKit.shared.searchUnifiedShareRecipients(query: query, account: account) + guard let share = result.recipients else { + state = .error(result.error) + return + } + + state = .recipientsUpdated(recipient: share) + } } } + diff --git a/Tests/NextcloudKitUnitTests/UnifiedShareDecodingTests.swift b/Tests/NextcloudKitUnitTests/UnifiedShareDecodingTests.swift new file mode 100644 index 00000000..25b1eba4 --- /dev/null +++ b/Tests/NextcloudKitUnitTests/UnifiedShareDecodingTests.swift @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Testing +@testable import NextcloudKit + +/// Verifies that `NKUnifiedShare` decodes through `NKOCSWrapper` and that the polymorphic +/// `properties` array yields the right concrete `NKUnifiedShareProperty` subclass per element. +@Suite("Unified share Codable") +struct UnifiedShareDecodingTests { + private func decodeShare(json: String) throws -> NKUnifiedShare { + let data = Data(json.utf8) + let wrap = try JSONDecoder().decode(NKOCSWrapper.self, from: data) + return wrap.ocs.data + } + + @Test("Decodes all five property variants into the matching subclass") + func decodesAllPropertyVariants() throws { + let json = """ + { + "ocs": { + "meta": { "status": "ok", "statuscode": 200 }, + "data": { + "id": "s1", + "owner": { + "user_id": "alice", + "instance": null, + "display_name": "Alice", + "icon": { "svg": "" } + }, + "last_updated": 1730000000000, + "state": "active", + "sources": [], + "recipients": [], + "permissions": [], + "properties": [ + { + "class": "expiration", + "display_name": "Expiration", + "hint": null, + "priority": 10, + "required": false, + "value": null, + "type": "date", + "min_date": "2026-01-01", + "max_date": null + }, + { + "class": "role", + "display_name": "Role", + "hint": null, + "priority": 20, + "required": true, + "value": "editor", + "type": "enum", + "valid_values": ["viewer", "editor"] + }, + { + "class": "download", + "display_name": "Allow download", + "hint": null, + "priority": 30, + "required": false, + "value": "true", + "type": "boolean" + }, + { + "class": "password", + "display_name": "Password", + "hint": "Min 8 chars", + "priority": 40, + "required": false, + "value": null, + "type": "password" + }, + { + "class": "note", + "display_name": "Note", + "hint": null, + "priority": 50, + "required": false, + "value": "hi", + "type": "string", + "min_length": 0, + "max_length": 1000 + } + ] + } + } + } + """ + + let share = try decodeShare(json: json) + + #expect(share.id == "s1") + #expect(share.state == .active) + #expect(share.lastUpdated == 1_730_000_000_000) + #expect(share.properties.count == 5) + + let p0 = try #require(share.properties[0] as? NKUnifiedSharePropertyDate) + #expect(p0.type == .date) + #expect(p0.minDate == "2026-01-01") + #expect(p0.maxDate == nil) + + let p1 = try #require(share.properties[1] as? NKUnifiedSharePropertyEnum) + #expect(p1.type == .enumeration) + #expect(p1.validValues == ["viewer", "editor"]) + + let p2 = try #require(share.properties[2] as? NKUnifiedSharePropertyBoolean) + #expect(p2.type == .boolean) + #expect(p2.value == "true") + + let p3 = try #require(share.properties[3] as? NKUnifiedSharePropertyPassword) + #expect(p3.type == .password) + #expect(p3.hint == "Min 8 chars") + + let p4 = try #require(share.properties[4] as? NKUnifiedSharePropertyString) + #expect(p4.type == .string) + #expect(p4.minLength == 0) + #expect(p4.maxLength == 1000) + } + + @Test("Decodes both Icon shapes (svg / light+dark)") + func decodesIconVariants() throws { + let json = """ + { + "ocs": { + "meta": { "status": "ok", "statuscode": 200 }, + "data": { + "id": "s2", + "owner": { + "user_id": "bob", + "instance": null, + "display_name": "Bob", + "icon": { "svg": "" } + }, + "last_updated": 0, + "state": "draft", + "sources": [ + { + "class": "file", + "value": "/foo.txt", + "display_name": "foo.txt", + "icon": { "light": "https://x/light.png", "dark": "https://x/dark.png" } + } + ], + "recipients": [], + "permissions": [], + "properties": [] + } + } + } + """ + + let share = try decodeShare(json: json) + + #expect(share.owner.icon.svg == "") + #expect(share.owner.icon.light == nil) + let source = try #require(share.sources.first) + #expect(source.icon?.svg == nil) + #expect(source.icon?.light == "https://x/light.png") + #expect(source.icon?.dark == "https://x/dark.png") + } + + @Test("Decodes a list response via NKOCSWrapper<[NKUnifiedShare]>") + func decodesShareListEnvelope() throws { + let json = """ + { + "ocs": { + "meta": { "status": "ok", "statuscode": 200, "message": "OK" }, + "data": [] + } + } + """ + let wrap = try JSONDecoder().decode(NKOCSWrapper<[NKUnifiedShare]>.self, from: Data(json.utf8)) + #expect(wrap.ocs.meta.statuscode == 200) + #expect(wrap.ocs.meta.message == "OK") + #expect(wrap.ocs.data.isEmpty) + } +} From 4cc682fa4c230866ae0a47e96d66a3203b5f5960 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 29 May 2026 20:26:38 +0200 Subject: [PATCH 4/7] Dropdown Signed-off-by: Milen Pivchev --- .../Unified Share/UnifiedShareView.swift | 92 ++++++++++++++++++- .../Unified Share/UnifiedShareViewModel.swift | 14 +-- 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift index 4a2ae723..fb1d3c86 100644 --- a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift @@ -16,6 +16,8 @@ public struct UnifiedShareView: View { @State private var isSettingsExpanded = true @State private var recipients = "" @State private var note = "" + @State private var addPeopleFieldHeight: CGFloat = 0 + @Environment(\.colorScheme) private var colorScheme public init(fileName: String, account: String) { self.fileName = fileName @@ -39,7 +41,7 @@ public struct UnifiedShareView: View { .font(.title) // .foregroundStyle(.primary) - audiencePicker + shareeTypePicker // VStack(spacing: 18) { if shareeType == .invited { @@ -48,6 +50,26 @@ public struct UnifiedShareView: View { text: $recipients ) .textFieldStyle(.roundedBorder) + .onChange(of: recipients) { + model.searchRecipients(query: recipients) + } + // Measure the field so the dropdown can sit just beneath it. + .background { + GeometryReader { proxy in + Color.clear + .onAppear { addPeopleFieldHeight = proxy.size.height } + .onChange(of: proxy.size.height) { addPeopleFieldHeight = proxy.size.height } + } + } + // Float the suggestions just below the field, overlaying the + // rest of the form instead of pushing it down. + .overlay(alignment: .topLeading) { + if !model.recipientResults.isEmpty { + recipientDropdown + .offset(y: addPeopleFieldHeight + 4) + } + } + .zIndex(1) } permissionField @@ -71,9 +93,12 @@ public struct UnifiedShareView: View { } .padding(.horizontal, 26) .padding(.top, 10) + .onAppear { + model.createShare() + } } - private var audiencePicker: some View { + private var shareeTypePicker: some View { Picker("", selection: $shareeType) { Text(String(localized: "Invited")) .tag(ShareeType.invited) @@ -105,6 +130,69 @@ public struct UnifiedShareView: View { } } + private var recipientDropdown: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(model.recipientResults, id: \.value) { recipient in + Button { + selectRecipient(recipient) + } label: { + HStack(spacing: 10) { + if let icon = recipient.icon { + recipientIcon(icon) + } + + Text(recipient.displayName) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if recipient.value != model.recipientResults.last?.value { + Divider() + } + } + } + } + .frame(height: dropdownHeight) + .background(.background) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(.quaternary) + } + .shadow(radius: 4, y: 2) + } + + /// Height of the suggestions dropdown: one row each, capped so it stays a dropdown. + private var dropdownHeight: CGFloat { + min(CGFloat(model.recipientResults.count) * 44, 220) + } + + /// Renders the icon's URL variant (color-scheme aware). Inline SVG isn't natively renderable. + @ViewBuilder + private func recipientIcon(_ icon: NKUnifiedShareIcon) -> some View { + if let urlString = (colorScheme == .dark ? icon.dark : icon.light) ?? icon.light ?? icon.dark, + let url = URL(string: urlString) { + AsyncImage(url: url) { image in + image + .resizable() + .scaledToFit() + } placeholder: { + ProgressView() + } + .frame(width: 24, height: 24) + .clipShape(Circle()) + } + } + + private func selectRecipient(_ recipient: NKUnifiedShareRecipient) { + recipients = "" + } + private var actionButtons: some View { HStack(spacing: 16) { Button(String(localized: "Copy link")) { diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareViewModel.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareViewModel.swift index e47514f1..8128cf50 100644 --- a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareViewModel.swift +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareViewModel.swift @@ -8,7 +8,6 @@ import NextcloudKit enum UnifiedShareViewState { case loading case shareUpdated(share: NKUnifiedShare) - case recipientsUpdated(recipient: [NKUnifiedShareRecipient]) case error(Error) } @@ -16,6 +15,8 @@ enum UnifiedShareViewState { @Observable public class UnifiedShareViewModel { var state: UnifiedShareViewState = .loading + /// Recipient autocomplete results — coexist with a loaded share, so kept out of `state`. + var recipientResults: [NKUnifiedShareRecipient] = [] let account: String init(account: String) { @@ -43,14 +44,15 @@ public class UnifiedShareViewModel { } func searchRecipients(query: String) { + guard !query.isEmpty else { + recipientResults = [] + return + } + Task { let result = await NextcloudKit.shared.searchUnifiedShareRecipients(query: query, account: account) - guard let share = result.recipients else { - state = .error(result.error) - return - } - state = .recipientsUpdated(recipient: share) + recipientResults = result.recipients ?? [] } } } From 56f752dbd8e872f77dcda9e14f568f7e4844f0e0 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Wed, 10 Jun 2026 11:13:56 +0200 Subject: [PATCH 5/7] WIP Signed-off-by: Milen Pivchev --- Package.resolved | 342 ++++++++++++++++++ ...odel.swift => UnifiedShareEditModel.swift} | 2 +- .../Unified Share/UnifiedShareEditView.swift | 248 +++++++++++++ .../Unified Share/UnifiedShareView.swift | 241 +----------- 4 files changed, 595 insertions(+), 238 deletions(-) rename Sources/NextcloudKitUI/Views/Unified Share/{UnifiedShareViewModel.swift => UnifiedShareEditModel.swift} (97%) create mode 100644 Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift diff --git a/Package.resolved b/Package.resolved index a2cfc080..9dbe86aa 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, { "identity" : "alamofire", "kind" : "remoteSourceControl", @@ -9,6 +18,15 @@ "version" : "5.10.2" } }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, { "identity" : "codescanner", "kind" : "remoteSourceControl", @@ -18,6 +36,159 @@ "version" : "2.5.2" } }, + { + "identity" : "dropdown", + "kind" : "remoteSourceControl", + "location" : "https://github.com/AssistoLab/DropDown", + "state" : { + "branch" : "master", + "revision" : "2ab6f6ce19f0117d1a76ea043ef8f57722c65d16" + } + }, + { + "identity" : "easytipview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marinofaggiana/EasyTipView", + "state" : { + "branch" : "master", + "revision" : "bb25a13c8fa5d65144c8e5b5d31237b80a001b07" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "9b3aed4fa6226125305b82d4d86c715bef250785", + "version" : "12.9.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "35b601a60fbbea2de3ea461f604deaaa4d8bbd0c", + "version" : "3.2.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "2ffd220823f3716904733162e9ae685545c276d1", + "version" : "12.8.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7", + "version" : "5.0.0" + } + }, + { + "identity" : "gzipswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/1024jp/GzipSwift", + "state" : { + "revision" : "99cf703b461916279cbbb50068e904df86c37133", + "version" : "6.0.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "keychainaccess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kishikawakatsumi/KeychainAccess", + "state" : { + "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7", + "version" : "4.2.2" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "lrucache", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nicklockwood/LRUCache", + "state" : { + "revision" : "cb5b2bd0da83ad29c0bec762d39f41c8ad0eaf3e", + "version" : "1.2.1" + } + }, + { + "identity" : "lucidbanner", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marinofaggiana/LucidBanner", + "state" : { + "revision" : "d7ea11cac476f32f63e0ad60f393bc7937b1d019", + "version" : "0.7.1" + } + }, + { + "identity" : "mantis", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marinofaggiana/Mantis", + "state" : { + "revision" : "d145ce2403e0d94d4926eb8510bc4966fbf1bfca", + "version" : "1.0.0" + } + }, + { + "identity" : "markdownkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/bmoliveira/MarkdownKit", + "state" : { + "revision" : "39ea7a3fc7e65bcafe55152592b834859be574a5", + "version" : "1.7.3" + } + }, { "identity" : "mocker", "kind" : "remoteSourceControl", @@ -27,6 +198,132 @@ "version" : "3.0.2" } }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "openssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/OpenSSL", + "state" : { + "revision" : "316422d246d859c2c07f47678440988940bb88ee", + "version" : "3.6.0" + } + }, + { + "identity" : "popupview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exyte/PopupView.git", + "state" : { + "revision" : "038f37d281a61842f96fffeb8903fa5c5e35ecf9", + "version" : "2.10.6" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, + { + "identity" : "qrcodereader.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/yannickl/QRCodeReader.swift", + "state" : { + "revision" : "5020b5a47199d8ba80c83a4b4fafd70e9dc9dc7f", + "version" : "10.1.1" + } + }, + { + "identity" : "queuer", + "kind" : "remoteSourceControl", + "location" : "https://github.com/FabrizioBrancati/Queuer", + "state" : { + "revision" : "52515108d0ac4616d9e15ffcc7ad986e300d31ff", + "version" : "2.1.1" + } + }, + { + "identity" : "realm-core", + "kind" : "remoteSourceControl", + "location" : "https://github.com/realm/realm-core.git", + "state" : { + "revision" : "4cc46f8607516226e5465062fdacd088fbd94552", + "version" : "20.1.4" + } + }, + { + "identity" : "realm-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/realm/realm-swift", + "state" : { + "revision" : "600e187711e5fa4e8e2c0429cacee27e8e44f112", + "version" : "20.0.4" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "9b92dcd5c22ae17016ad867852e0850f1f9f93ed", + "version" : "2.94.1" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl", + "state" : { + "revision" : "173cc69a058623525a58ae6710e2f5727c663793", + "version" : "2.36.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "swifter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/httpswift/swifter.git", + "state" : { + "branch" : "stable", + "revision" : "1e4f51c92d7ca486242d8bf0722b99de2c3531aa" + } + }, { "identity" : "swiftlintplugins", "kind" : "remoteSourceControl", @@ -36,6 +333,24 @@ "version" : "0.63.3" } }, + { + "identity" : "swiftrichstring", + "kind" : "remoteSourceControl", + "location" : "https://github.com/malcommac/SwiftRichString", + "state" : { + "revision" : "9bf4b5af6bb4386865636fc504d6c588c2b65040", + "version" : "3.7.2" + } + }, + { + "identity" : "swiftui-introspect", + "kind" : "remoteSourceControl", + "location" : "https://github.com/siteline/swiftui-introspect", + "state" : { + "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", + "version" : "1.3.0" + } + }, { "identity" : "swiftyjson", "kind" : "remoteSourceControl", @@ -53,6 +368,33 @@ "revision" : "d7a1d23f04c86c1cd2e8f19247dd15d74e0ea8be", "version" : "5.6.0" } + }, + { + "identity" : "taglistview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ElaWorkshop/TagListView", + "state" : { + "revision" : "b30b61826736f4d9ec04aef5df891f3914423f16", + "version" : "1.4.1" + } + }, + { + "identity" : "tlphotopicker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tilltue/TLPhotoPicker", + "state" : { + "revision" : "bf4a14650ba3adf0890644b91dfb9601da6ba38c", + "version" : "2.1.18" + } + }, + { + "identity" : "vlckit-spm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tylerjonesio/vlckit-spm", + "state" : { + "revision" : "e932bbd488872fdb74f6654d28c2f291eae03daf", + "version" : "3.6.0" + } } ], "version" : 2 diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareViewModel.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift similarity index 97% rename from Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareViewModel.swift rename to Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift index 8128cf50..53afba1b 100644 --- a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareViewModel.swift +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift @@ -13,7 +13,7 @@ enum UnifiedShareViewState { @MainActor @Observable -public class UnifiedShareViewModel { +public class UnifiedShareEditModel { var state: UnifiedShareViewState = .loading /// Recipient autocomplete results — coexist with a loaded share, so kept out of `state`. var recipientResults: [NKUnifiedShareRecipient] = [] diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift new file mode 100644 index 00000000..290b7eac --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import NextcloudKit + +/// View used for Unified Sharing. +public struct UnifiedShareEditView: View { + let fileName: String + let account: String + @State private var model: UnifiedShareEditModel + + @State private var shareeType: ShareeType = .invited + @State private var permission: Permission = .canView + @State private var isSettingsExpanded = true + @State private var recipients = "" + @State private var note = "" + @State private var addPeopleFieldHeight: CGFloat = 0 + @Environment(\.colorScheme) private var colorScheme + + public init(fileName: String, account: String) { + self.fileName = fileName + self.account = account + model = UnifiedShareEditModel(account: account) + } + + init(fileName: String, model: UnifiedShareEditModel) { + self.fileName = fileName + self.account = model.account + self.model = model + } + + public var body: some View { + VStack(alignment: .leading, spacing: 24) { + switch model.state { + case .loading: + ProgressView() + case .shareUpdated(let share): + Text(String(localized: "Share \(fileName)")) + .font(.title) + // .foregroundStyle(.primary) + + shareeTypePicker + + // VStack(spacing: 18) { + if shareeType == .invited { + TextField( + String(localized: "Add people"), + text: $recipients + ) + .textFieldStyle(.roundedBorder) + .onChange(of: recipients) { + model.searchRecipients(query: recipients) + } + // Measure the field so the dropdown can sit just beneath it. + .background { + GeometryReader { proxy in + Color.clear + .onAppear { addPeopleFieldHeight = proxy.size.height } + .onChange(of: proxy.size.height) { addPeopleFieldHeight = proxy.size.height } + } + } + // Float the suggestions just below the field, overlaying the + // rest of the form instead of pushing it down. + .overlay(alignment: .topLeading) { + if !model.recipientResults.isEmpty { + recipientDropdown + .offset(y: addPeopleFieldHeight + 4) + } + } + .zIndex(1) + } + + permissionField + settingsRow + + TextField( + String(localized: "Note to recipients"), + text: $note, + axis: .vertical + ) + .textFieldStyle(.roundedBorder) + + actionButtons + + case .error(let error): + Text(error.localizedDescription) + } + + // } + + } + .padding(.horizontal, 26) + .padding(.top, 10) + .onAppear { + model.createShare() + } + } + + private var shareeTypePicker: some View { + Picker("", selection: $shareeType) { + Text(String(localized: "Invited")) + .tag(ShareeType.invited) + + Text(String(localized: "Anyone")) + .tag(ShareeType.anyone) + } + .pickerStyle(.segmented) + } + + private var permissionField: some View { + LabeledContent(String(localized: shareeType == .anyone ? "Anyone with the link" : "Participants")) { + Picker(String(localized: "Participants"), selection: $permission) { + ForEach(Permission.allCases) { permission in + Text(permission.localizedTitle) + .tag(permission) + } + } + .pickerStyle(.menu) + } + } + + private var settingsRow: some View { + DisclosureGroup(isExpanded: $isSettingsExpanded) { + Text("Test") + Text("Test") + } label: { + Text(String(localized: "Settings")) + } + } + + private var recipientDropdown: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(model.recipientResults, id: \.value) { recipient in + Button { + selectRecipient(recipient) + } label: { + HStack(spacing: 10) { + if let icon = recipient.icon { + recipientIcon(icon) + } + + Text(recipient.displayName) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if recipient.value != model.recipientResults.last?.value { + Divider() + } + } + } + } + .frame(height: dropdownHeight) + .background(.background) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(.quaternary) + } + .shadow(radius: 4, y: 2) + } + + /// Height of the suggestions dropdown: one row each, capped so it stays a dropdown. + private var dropdownHeight: CGFloat { + min(CGFloat(model.recipientResults.count) * 44, 220) + } + + /// Renders the icon's URL variant (color-scheme aware). Inline SVG isn't natively renderable. + @ViewBuilder + private func recipientIcon(_ icon: NKUnifiedShareIcon) -> some View { + if let urlString = (colorScheme == .dark ? icon.dark : icon.light) ?? icon.light ?? icon.dark, + let url = URL(string: urlString) { + AsyncImage(url: url) { image in + image + .resizable() + .scaledToFit() + } placeholder: { + ProgressView() + } + .frame(width: 24, height: 24) + .clipShape(Circle()) + } + } + + private func selectRecipient(_ recipient: NKUnifiedShareRecipient) { + recipients = "" + } + + private var actionButtons: some View { + HStack(spacing: 16) { + Button(String(localized: "Copy link")) { + } + .buttonStyle(.bordered) + .frame(maxWidth: .infinity) + + Button(String(localized: "Send")) { + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + } + .padding(.top, 18) + } +} + +private extension UnifiedShareEditView { + enum ShareeType { + case invited + case anyone + } + + enum Permission: CaseIterable, Identifiable { + case canView + case canEdit + case fileDrop + case customPermissions + + var id: Self { + self + } + + var localizedTitle: String { + switch self { + case .canView: + String(localized: "Can view") + case .canEdit: + String(localized: "Can edit") + case .fileDrop: + String(localized: "File drop") + case .customPermissions: + String(localized: "Custom permissions") + } + } + } +} + +#Preview { + UnifiedShareEditView( + fileName: "Test.txt", + model: UnifiedShareEditModel(account: "", state: .shareUpdated(share: .mock)) + ) +} diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift index fb1d3c86..a7cb5fef 100644 --- a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareView.swift @@ -3,246 +3,13 @@ // SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI -import NextcloudKit -/// View used for Unified Sharing. -public struct UnifiedShareView: View { - let fileName: String - let account: String - @State private var model: UnifiedShareViewModel - - @State private var shareeType: ShareeType = .invited - @State private var permission: Permission = .canView - @State private var isSettingsExpanded = true - @State private var recipients = "" - @State private var note = "" - @State private var addPeopleFieldHeight: CGFloat = 0 - @Environment(\.colorScheme) private var colorScheme - - public init(fileName: String, account: String) { - self.fileName = fileName - self.account = account - model = UnifiedShareViewModel(account: account) - } - - init(fileName: String, model: UnifiedShareViewModel) { - self.fileName = fileName - self.account = model.account - self.model = model - } - - public var body: some View { - VStack(alignment: .leading, spacing: 24) { - switch model.state { - case .loading: - ProgressView() - case .shareUpdated(let share): - Text(String(localized: "Share \(fileName)")) - .font(.title) - // .foregroundStyle(.primary) - - shareeTypePicker - - // VStack(spacing: 18) { - if shareeType == .invited { - TextField( - String(localized: "Add people"), - text: $recipients - ) - .textFieldStyle(.roundedBorder) - .onChange(of: recipients) { - model.searchRecipients(query: recipients) - } - // Measure the field so the dropdown can sit just beneath it. - .background { - GeometryReader { proxy in - Color.clear - .onAppear { addPeopleFieldHeight = proxy.size.height } - .onChange(of: proxy.size.height) { addPeopleFieldHeight = proxy.size.height } - } - } - // Float the suggestions just below the field, overlaying the - // rest of the form instead of pushing it down. - .overlay(alignment: .topLeading) { - if !model.recipientResults.isEmpty { - recipientDropdown - .offset(y: addPeopleFieldHeight + 4) - } - } - .zIndex(1) - } - - permissionField - settingsRow - - TextField( - String(localized: "Note to recipients"), - text: $note, - axis: .vertical - ) - .textFieldStyle(.roundedBorder) - - actionButtons - - case .error(let error): - Text("Error") - } - - // } - - } - .padding(.horizontal, 26) - .padding(.top, 10) - .onAppear { - model.createShare() - } - } - - private var shareeTypePicker: some View { - Picker("", selection: $shareeType) { - Text(String(localized: "Invited")) - .tag(ShareeType.invited) - - Text(String(localized: "Anyone")) - .tag(ShareeType.anyone) - } - .pickerStyle(.segmented) - } - - private var permissionField: some View { - LabeledContent(String(localized: shareeType == .anyone ? "Anyone with the link" : "Participants")) { - Picker(String(localized: "Participants"), selection: $permission) { - ForEach(Permission.allCases) { permission in - Text(permission.localizedTitle) - .tag(permission) - } - } - .pickerStyle(.menu) - } - } - - private var settingsRow: some View { - DisclosureGroup(isExpanded: $isSettingsExpanded) { - Text("Test") - Text("Test") - } label: { - Text(String(localized: "Settings")) - } - } - - private var recipientDropdown: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - ForEach(model.recipientResults, id: \.value) { recipient in - Button { - selectRecipient(recipient) - } label: { - HStack(spacing: 10) { - if let icon = recipient.icon { - recipientIcon(icon) - } - - Text(recipient.displayName) - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - if recipient.value != model.recipientResults.last?.value { - Divider() - } - } - } - } - .frame(height: dropdownHeight) - .background(.background) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(.quaternary) - } - .shadow(radius: 4, y: 2) - } - - /// Height of the suggestions dropdown: one row each, capped so it stays a dropdown. - private var dropdownHeight: CGFloat { - min(CGFloat(model.recipientResults.count) * 44, 220) - } - - /// Renders the icon's URL variant (color-scheme aware). Inline SVG isn't natively renderable. - @ViewBuilder - private func recipientIcon(_ icon: NKUnifiedShareIcon) -> some View { - if let urlString = (colorScheme == .dark ? icon.dark : icon.light) ?? icon.light ?? icon.dark, - let url = URL(string: urlString) { - AsyncImage(url: url) { image in - image - .resizable() - .scaledToFit() - } placeholder: { - ProgressView() - } - .frame(width: 24, height: 24) - .clipShape(Circle()) - } - } - - private func selectRecipient(_ recipient: NKUnifiedShareRecipient) { - recipients = "" - } - - private var actionButtons: some View { - HStack(spacing: 16) { - Button(String(localized: "Copy link")) { - } - .buttonStyle(.bordered) - .frame(maxWidth: .infinity) - - Button(String(localized: "Send")) { - } - .buttonStyle(.borderedProminent) - .frame(maxWidth: .infinity) - } - .padding(.top, 18) - } -} - -private extension UnifiedShareView { - enum ShareeType { - case invited - case anyone - } - - enum Permission: CaseIterable, Identifiable { - case canView - case canEdit - case fileDrop - case customPermissions - - var id: Self { - self - } - - var localizedTitle: String { - switch self { - case .canView: - String(localized: "Can view") - case .canEdit: - String(localized: "Can edit") - case .fileDrop: - String(localized: "File drop") - case .customPermissions: - String(localized: "Custom permissions") - } - } +struct UnifiedShareView: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) } } #Preview { - UnifiedShareView( - fileName: "Test.txt", - model: UnifiedShareViewModel(account: "", state: .shareUpdated(share: .mock)) - ) + UnifiedShareView() } From f6a431b88f150d65a6d392cc6a9e5b3c45f87615 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Tue, 23 Jun 2026 18:27:37 +0200 Subject: [PATCH 6/7] WIP Signed-off-by: Milen Pivchev --- .../UnifiedSharing/NKUnifiedShare+Mock.swift | 14 +-- .../Unified Share/UnifiedShareEditModel.swift | 18 +++ .../Unified Share/UnifiedShareEditView.swift | 118 ++++++++++-------- 3 files changed, 93 insertions(+), 57 deletions(-) diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare+Mock.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare+Mock.swift index d09c972b..72bb55ee 100644 --- a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare+Mock.swift +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare+Mock.swift @@ -27,13 +27,13 @@ public extension NKUnifiedShare { ) ], recipients: [ - NKUnifiedShareRecipient( - class: "user", - value: "bob", - instance: nil, - displayName: "Bob", - icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil) - ) +// NKUnifiedShareRecipient( +// class: "user", +// value: "bob", +// instance: nil, +// displayName: "Bob", +// icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil) +// ) ], properties: [ NKUnifiedSharePropertyDate( diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift index 53afba1b..51748753 100644 --- a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift @@ -55,5 +55,23 @@ public class UnifiedShareEditModel { recipientResults = result.recipients ?? [] } } + + func deleteShare(share: NKUnifiedShare) { + Task { + await NextcloudKit.shared.deleteUnifiedShare(id: share.id, account: account) + } + } + + func updateShare(share: NKUnifiedShare, recipient: NKUnifiedShareRecipient) { + Task { + let result = await NextcloudKit.shared.addUnifiedShareRecipient(id: share.id, recipientClass: recipient.class, value: recipient.value, account: account) + guard let share = result.share else { + state = .error(result.error) + return + } + + state = .shareUpdated(share: share) + } + } } diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift index 290b7eac..714bd88c 100644 --- a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift @@ -32,85 +32,103 @@ public struct UnifiedShareEditView: View { } public var body: some View { - VStack(alignment: .leading, spacing: 24) { + ZStack { switch model.state { case .loading: ProgressView() case .shareUpdated(let share): - Text(String(localized: "Share \(fileName)")) - .font(.title) - // .foregroundStyle(.primary) - - shareeTypePicker - - // VStack(spacing: 18) { - if shareeType == .invited { - TextField( - String(localized: "Add people"), - text: $recipients - ) - .textFieldStyle(.roundedBorder) - .onChange(of: recipients) { - model.searchRecipients(query: recipients) - } - // Measure the field so the dropdown can sit just beneath it. - .background { - GeometryReader { proxy in - Color.clear - .onAppear { addPeopleFieldHeight = proxy.size.height } - .onChange(of: proxy.size.height) { addPeopleFieldHeight = proxy.size.height } - } + Form { +// VStack(alignment: .leading, spacing: 24) { + Section { + Text(String(localized: "Share \(fileName)")) + .font(.title) + // .foregroundStyle(.primary) + } - // Float the suggestions just below the field, overlaying the - // rest of the form instead of pushing it down. - .overlay(alignment: .topLeading) { - if !model.recipientResults.isEmpty { - recipientDropdown - .offset(y: addPeopleFieldHeight + 4) + + Section { + shareeTypePicker + + // VStack(spacing: 18) { + if shareeType == .invited && share.recipients.isEmpty { + TextField( + String(localized: "Add people"), + text: $recipients + ) + .onChange(of: recipients) { + model.searchRecipients(query: recipients) + } + // Measure the field so the dropdown can sit just beneath it. + .background { + GeometryReader { proxy in + Color.clear + .onAppear { addPeopleFieldHeight = proxy.size.height } + .onChange(of: proxy.size.height) { addPeopleFieldHeight = proxy.size.height } + } + } + .overlay(alignment: .topLeading) { +// if !model.recipientResults.isEmpty { + recipientDropdown + .offset(y: 30 + 4) + +// } + } + .zIndex(1) + } else if let recipient = share.recipients.first { + Text(recipient.displayName) } + + permissionField } - .zIndex(1) - } + settingsRow - permissionField - settingsRow + TextField( + String(localized: "Note to recipients"), + text: $note, + axis: .vertical + ) - TextField( - String(localized: "Note to recipients"), - text: $note, - axis: .vertical - ) - .textFieldStyle(.roundedBorder) + actionButtons +// } - actionButtons + } +//// .padding(.horizontal, 26) +// .padding(.top, 10) + .onDisappear { + model.deleteShare(share: share) + } + .navigationTitle("Share") case .error(let error): Text(error.localizedDescription) } - + // } - + + } - .padding(.horizontal, 26) - .padding(.top, 10) .onAppear { - model.createShare() +// model.createShare() } - } + + Spacer() +} private var shareeTypePicker: some View { Picker("", selection: $shareeType) { - Text(String(localized: "Invited")) + Text(String(localized: "Invited People")) .tag(ShareeType.invited) Text(String(localized: "Anyone")) .tag(ShareeType.anyone) } .pickerStyle(.segmented) + .listRowSeparator(.hidden) + } private var permissionField: some View { - LabeledContent(String(localized: shareeType == .anyone ? "Anyone with the link" : "Participants")) { +// LabeledContent(String(localized: shareeType == .anyone ? "Anyone with the link" : "Participants")) { Picker(String(localized: "Participants"), selection: $permission) { ForEach(Permission.allCases) { permission in Text(permission.localizedTitle) @@ -118,7 +136,7 @@ public struct UnifiedShareEditView: View { } } .pickerStyle(.menu) - } +// } } private var settingsRow: some View { From 23aa3243a8d009f29b81fa8b7e997977109ed621 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Thu, 23 Jul 2026 09:33:55 +0200 Subject: [PATCH 7/7] WIP Signed-off-by: Milen Pivchev --- .../NKUnifiedShareRecipient+Mock.swift | 31 ++++++++ .../Unified Share/UnifiedShareEditModel.swift | 5 +- .../Unified Share/UnifiedShareEditView.swift | 71 +++++++++++-------- 3 files changed, 74 insertions(+), 33 deletions(-) create mode 100644 Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient+Mock.swift diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient+Mock.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient+Mock.swift new file mode 100644 index 00000000..23e95ab1 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient+Mock.swift @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +#if DEBUG +import Foundation + +public extension NKUnifiedShareRecipient { + /// Sample recipient for SwiftUI previews and tests. + static var mock: NKUnifiedShareRecipient { + NKUnifiedShareRecipient( + class: "user", + value: "bob", + instance: nil, + displayName: "Bob", + icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil) + ) + } +} + +public extension Array where Element == NKUnifiedShareRecipient { + /// A few sample recipients, e.g. for autocomplete results. + static var mocks: [NKUnifiedShareRecipient] { + [ + NKUnifiedShareRecipient(class: "", value: "bob", instance: nil, displayName: "Bob", icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil)), + NKUnifiedShareRecipient(class: "", value: "team", instance: nil, displayName: "Team", icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil)), + NKUnifiedShareRecipient(class: "", value: "carol@example.com", instance: "example.com", displayName: "Carol (example.com)", icon: nil) + ] + } +} +#endif diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift index 51748753..dd8470af 100644 --- a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift @@ -25,9 +25,10 @@ public class UnifiedShareEditModel { #if DEBUG /// Preview-only initializer that starts in a given state. - init(account: String, state: UnifiedShareViewState) { + init(account: String, state: UnifiedShareViewState, recipientResults: [NKUnifiedShareRecipient] = []) { self.account = account self.state = state + self.recipientResults = recipientResults } #endif @@ -62,7 +63,7 @@ public class UnifiedShareEditModel { } } - func updateShare(share: NKUnifiedShare, recipient: NKUnifiedShareRecipient) { + func addRecipient(share: NKUnifiedShare, recipient: NKUnifiedShareRecipient) { Task { let result = await NextcloudKit.shared.addUnifiedShareRecipient(id: share.id, recipientClass: recipient.class, value: recipient.value, account: account) guard let share = result.share else { diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift index 714bd88c..0d9d7aa6 100644 --- a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift @@ -16,7 +16,6 @@ public struct UnifiedShareEditView: View { @State private var isSettingsExpanded = true @State private var recipients = "" @State private var note = "" - @State private var addPeopleFieldHeight: CGFloat = 0 @Environment(\.colorScheme) private var colorScheme public init(fileName: String, account: String) { @@ -37,6 +36,7 @@ public struct UnifiedShareEditView: View { case .loading: ProgressView() case .shareUpdated(let share): + Form { // VStack(alignment: .leading, spacing: 24) { Section { @@ -58,37 +58,24 @@ public struct UnifiedShareEditView: View { .onChange(of: recipients) { model.searchRecipients(query: recipients) } - // Measure the field so the dropdown can sit just beneath it. - .background { - GeometryReader { proxy in - Color.clear - .onAppear { addPeopleFieldHeight = proxy.size.height } - .onChange(of: proxy.size.height) { addPeopleFieldHeight = proxy.size.height } - } - } - .overlay(alignment: .topLeading) { -// if !model.recipientResults.isEmpty { - recipientDropdown - .offset(y: 30 + 4) + // Publish the field's frame so the dropdown can be drawn outside the Form. + .anchorPreference(key: AddPeopleFieldAnchorKey.self, value: .bounds) { $0 } -// } - } - .zIndex(1) } else if let recipient = share.recipients.first { Text(recipient.displayName) } permissionField } - settingsRow + settingsRow - TextField( - String(localized: "Note to recipients"), - text: $note, - axis: .vertical - ) + TextField( + String(localized: "Note to recipients"), + text: $note, + axis: .vertical + ) - actionButtons + actionButtons // } } @@ -98,6 +85,19 @@ public struct UnifiedShareEditView: View { model.deleteShare(share: share) } .navigationTitle("Share") + // Draw the dropdown above the Form, anchored just beneath the field, so the + // Form's row clipping can't cut it off. + .overlayPreferenceValue(AddPeopleFieldAnchorKey.self) { anchor in + GeometryReader { proxy in + if let anchor, !model.recipientResults.isEmpty { + let frame = proxy[anchor] + + recipientDropdown(share: share) + .frame(width: frame.width) + .offset(x: frame.minX, y: frame.maxY + 4) + } + } + } case .error(let error): Text(error.localizedDescription) @@ -107,8 +107,8 @@ public struct UnifiedShareEditView: View { } - .onAppear { -// model.createShare() + .task { + model.createShare() } Spacer() @@ -148,12 +148,12 @@ public struct UnifiedShareEditView: View { } } - private var recipientDropdown: some View { + private func recipientDropdown(share: NKUnifiedShare) -> some View { ScrollView { VStack(alignment: .leading, spacing: 0) { ForEach(model.recipientResults, id: \.value) { recipient in Button { - selectRecipient(recipient) + model.addRecipient(share: share, recipient: recipient) } label: { HStack(spacing: 10) { if let icon = recipient.icon { @@ -207,9 +207,9 @@ public struct UnifiedShareEditView: View { } } - private func selectRecipient(_ recipient: NKUnifiedShareRecipient) { - recipients = "" - } +// private func selectRecipient(_ recipient: NKUnifiedShareRecipient) { +// recipients = "" +// } private var actionButtons: some View { HStack(spacing: 16) { @@ -227,6 +227,15 @@ public struct UnifiedShareEditView: View { } } +/// Carries the "Add people" field's frame up to the ZStack so the dropdown can sit beneath it. +private struct AddPeopleFieldAnchorKey: PreferenceKey { + static let defaultValue: Anchor? = nil + + static func reduce(value: inout Anchor?, nextValue: () -> Anchor?) { + value = value ?? nextValue() + } +} + private extension UnifiedShareEditView { enum ShareeType { case invited @@ -261,6 +270,6 @@ private extension UnifiedShareEditView { #Preview { UnifiedShareEditView( fileName: "Test.txt", - model: UnifiedShareEditModel(account: "", state: .shareUpdated(share: .mock)) + model: UnifiedShareEditModel(account: "", state: .shareUpdated(share: .mock), recipientResults: .mocks) ) }