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..b8481a27 --- /dev/null +++ b/Sources/NextcloudKitUI/Documentation.docc/Documentation.md @@ -0,0 +1,39 @@ +# ``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. Business logic such as account persistence and post-login bootstrap is left to consumers through closures. The shared Login Flow v2 (server status, login flow request and polling) is provided here on top of [NextcloudKit](https://github.com/nextcloud/nextcloudkit) so every app shares one implementation. +- **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`` + +### 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..0399f7ec --- /dev/null +++ b/Sources/NextcloudKitUI/Features/QRCodeParsing.swift @@ -0,0 +1,141 @@ +// 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 +} + +/// +/// Credentials parsed from a login QR code, tagged with which login variant they represent. +/// +struct ParsedLoginCredentials { + /// + /// Whether the password is a ready-to-use app password (`login`) or a onetime token that + /// still has to be exchanged for an app password (`onetimeLogin`). + /// + enum Kind { + case login + case onetimeLogin + } + + let kind: Kind + let user: String + + /// + /// The app password for ``Kind/login`` or the onetime token for ``Kind/onetimeLogin``. + /// + let password: String + let host: URL +} + +/// +/// 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) { + try decomposeLoginCode(code, prefix: "nc://login/") + } + + /// + /// Parse a login QR code into ``ParsedLoginCredentials``, distinguishing direct logins + /// (`nc://login/…`) from onetime logins (`nc://onetime-login/…`). + /// + func parseLogin(_ code: String) throws -> ParsedLoginCredentials { + let onetimePrefix = "nc://onetime-login/" + let loginPrefix = "nc://login/" + + let kind: ParsedLoginCredentials.Kind + let prefix: String + + if code.hasPrefix(onetimePrefix) { + kind = .onetimeLogin + prefix = onetimePrefix + } else if code.hasPrefix(loginPrefix) { + kind = .login + prefix = loginPrefix + } else { + throw QRCodeError.invalidPrefix + } + + let (user, password, host) = try decomposeLoginCode(code, prefix: prefix) + return ParsedLoginCredentials(kind: kind, user: user, password: password, host: host) + } +} + +/// +/// Decompose a `user:…&password:…&server:…` login code into its values. +/// +private func decomposeLoginCode(_ code: String, prefix loginCodePrefix: String) throws -> (user: String, password: String, host: URL) { + 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..98f718a0 --- /dev/null +++ b/Sources/NextcloudKitUI/Localizable.xcstrings @@ -0,0 +1,5229 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Accounts" : { + "localizations" : { + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accounts" + } + }, + "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" : { + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accounts from other Apps" + } + }, + "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" : { + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Login Failed" + } + }, + "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" : { + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "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" : { + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scan QR Code" + } + }, + "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" : { + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server Address" + } + }, + "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" : { + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "The address of your Nextcloud web interface when you open it in your browser." + } + }, + "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" : { + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "The entered server address is invalid." + } + }, + "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" +} 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..3ff02a8c --- /dev/null +++ b/Sources/NextcloudKitUI/Modifiers/WebViewSheet.swift @@ -0,0 +1,59 @@ +// 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 { + #if os(iOS) + content.fullScreenCover(isPresented: $isPresented, onDismiss: onDismiss) { + WebView(initialURL: $initialURL, userAgent: userAgent) + .ignoresSafeArea() + } + #else + content.sheet(isPresented: $isPresented, onDismiss: onDismiss) { + WebView(initialURL: $initialURL, userAgent: userAgent) + .ignoresSafeArea() + .frame(minWidth: 800, minHeight: 800) + } + #endif + } +} + +extension View { + /// + /// Present a ``WebView`` full screen (a sheet on macOS, where full screen covers are unavailable). + /// + /// 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/Login/AccountButtonView.swift b/Sources/NextcloudKitUI/Views/Login/AccountButtonView.swift new file mode 100644 index 00000000..84168391 --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Login/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/Login/AlternativeLoginMethodsView.swift b/Sources/NextcloudKitUI/Views/Login/AlternativeLoginMethodsView.swift new file mode 100644 index 00000000..0b35ba1f --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Login/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/Login/FormDetailView.swift b/Sources/NextcloudKitUI/Views/Login/FormDetailView.swift new file mode 100644 index 00000000..c60af5c7 --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Login/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/Login/LoginFlowModel.swift b/Sources/NextcloudKitUI/Views/Login/LoginFlowModel.swift new file mode 100644 index 00000000..992b2bfe --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Login/LoginFlowModel.swift @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +#if os(iOS) +import CodeScanner +#endif +import NextcloudKit +import SwiftUI + +/// +/// Drives the Login Flow v2 for ``ServerAddressView``. +/// +/// The flow is identical across all consuming apps, so it lives in the package: check the server +/// status, request the login flow, present its web page, and poll until the server grants an app +/// password. The consumer only provides ``AddAccountHandler`` to persist the resulting credentials. +/// +@MainActor +@Observable +final class LoginFlowModel: QRCodeParsing, URLSanitizing { + private let userAgent: String? + private let addAccount: AddAccountHandler + + init(userAgent: String?, addAccount: @escaping AddAccountHandler) { + self.userAgent = userAgent + self.addAccount = addAccount + } + + // MARK: - State observed by the view + + /// The unsanitized user input. + var enteredServerAddress = "" + + /// Message to display in case of error. + var errorMessage: String? + + /// Whether an activity currently requires disabling the user interface. + var isActive = false + + /// State toggle for presenting the error alert. + var isPresentingAlert = false + + /// State toggle for presenting the web view. + var isPresentingWebView = false + + /// The login address acquired from the server through the login flow API. + var loginAddress: URL? + + private var pollingTask: Task? + + // MARK: - Login flow + + /// + /// Sanitize the entered server address and start the login flow. + /// + func logIn() { + guard enteredServerAddress.trimmingCharacters(in: .whitespaces).isEmpty == false else { + return + } + + guard let sanitizedServerAddress = sanitize(enteredServerAddress) else { + present(error: String(localized: "The entered server address is invalid.", comment: "This is an error message.")) + return + } + + start(serverURL: sanitizedServerAddress) + } + + /// + /// Check the server, request the login flow, present its web page and begin polling. + /// + func start(serverURL: URL) { + isActive = true + + Task { + do { + let options = NKRequestOptions(customUserAgent: userAgent) + + let (_, statusResult) = await NextcloudKit.shared.getServerStatusAsync(serverUrl: serverURL.absoluteString, options: options) + + if case .failure(let error) = statusResult { + throw error + } + + let (endpoint, login, token) = try await NextcloudKit.shared.getLoginFlowV2(serverUrl: serverURL.absoluteString, options: options) + + startPolling(token: token, endpoint: endpoint) + + loginAddress = login + isPresentingWebView = true + } catch { + endLogin(error) + } + } + } + + private func startPolling(token: String, endpoint: URL) { + pollingTask?.cancel() + + pollingTask = Task { [weak self] in + guard let self else { return } + + let options = NKRequestOptions(customUserAgent: self.userAgent) + + while Task.isCancelled == false { + let poll = await NextcloudKit.shared.getLoginFlowV2PollAsync(token: token, endpoint: endpoint.absoluteString, options: options) + + if poll.error == .success, + let server = poll.server, let host = URL(string: server), + let loginName = poll.loginName, + let appPassword = poll.appPassword { + self.complete(host: host, name: loginName, password: appPassword) + return + } + + try? await Task.sleep(for: .seconds(1)) + } + } + } + + private func complete(host: URL, name: String, password: String) { + pollingTask = nil + isActive = false + isPresentingWebView = false + addAccount(host, name, password) + } + + /// + /// Stop polling. Called when the web view is dismissed, regardless of the reason. + /// + func cancel() { + pollingTask?.cancel() + pollingTask = nil + isActive = false + isPresentingWebView = false + } + + private func endLogin(_ error: Error?) { + cancel() + + if let error { + let message = (error as? NKError)?.errorDescription ?? error.localizedDescription + present(error: message) + } + } + + private func present(error: String?) { + errorMessage = error + isPresentingAlert = true + } + + // MARK: - QR code + + #if os(iOS) + func handleQRCodeScan(_ result: Result) { + switch result { + case .success(let result): + handleLoginCode(result.string) + case .failure(let error): + present(error: error.localizedDescription) + } + } + #endif + + func handleLoginCode(_ code: String) { + do { + let credentials = try parseLogin(code) + + switch credentials.kind { + case .login: + addAccount(credentials.host, credentials.user, credentials.password) + case .onetimeLogin: + exchangeOnetimeToken(credentials) + } + } catch { + present(error: error.localizedDescription) + } + } + + private func exchangeOnetimeToken(_ credentials: ParsedLoginCredentials) { + isActive = true + + Task { + let result = await NextcloudKit.shared.getAppPasswordOnetimeAsync(url: credentials.host.absoluteString, user: credentials.user, onetimeToken: credentials.password, userAgent: userAgent) + + isActive = false + + if result.error == .success, let token = result.token { + addAccount(credentials.host, credentials.user, token) + } else { + present(error: result.error.errorDescription) + } + } + } + + // MARK: - Shared accounts + + func selectSharedAccount(_ account: SharedAccount) { + enteredServerAddress = account.host.absoluteString + // Username prefill on the login URL is disabled until https://github.com/nextcloud/server/issues/59874 is resolved. + logIn() + } +} diff --git a/Sources/NextcloudKitUI/Views/Login/ServerAddressView.swift b/Sources/NextcloudKitUI/Views/Login/ServerAddressView.swift new file mode 100644 index 00000000..1a64c428 --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Login/ServerAddressView.swift @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +/// +/// A new remote user account should be added locally in the persistence of the app. +/// +/// This is called once the login flow (or a QR code scan) has produced credentials. The consumer +/// is responsible for persisting the account and any post-login bootstrap. +/// +public typealias AddAccountHandler = (_ host: URL, _ name: String, _ password: String) -> Void + +/// +/// The full screen view in which a user enters the address of the server to log in on. +/// +public struct ServerAddressView: View { + 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``. + /// + public init(backgroundColor: Binding, brandImage: Image, sharedAccounts: [SharedAccount], userAgent: String? = nil, addAccount: @escaping AddAccountHandler) { + self._backgroundColor = backgroundColor + self.brandImage = brandImage + self.sharedAccounts = sharedAccounts + self.userAgent = userAgent + self._model = State(initialValue: LoginFlowModel(userAgent: userAgent, addAccount: addAccount)) + } + + // MARK: - Environment + + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @Environment(\.verticalSizeClass) var verticalSizeClass + + // MARK: - Bindings + + @Binding var backgroundColor: Color + + // MARK: - State + + /// + /// The login flow engine. Owns the server address input, polling and web view state. + /// + @State private var model: LoginFlowModel + + // MARK: - Implementation + + public var body: some View { + @Bindable var model = model + + return 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: $model.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 { + model.logIn() + } + + if model.isActive { + ProgressView() + .progressViewStyle(.circular) + .tint(backgroundColor.readable) + } else { + Button { + model.logIn() + } 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: { model.handleQRCodeScan($0) }, selectionHandler: { model.selectSharedAccount($0) }) + #else + AlternativeLoginMethodsView(sharedAccounts: sharedAccounts, selectionHandler: { model.selectSharedAccount($0) }) + #endif + + Spacer() + } + .disabled(model.isActive) + .tint(backgroundColor.readable) + .padding() + .safeAreaPadding(.all) + } + .ignoresSafeArea() + .webSheet(initialURL: $model.loginAddress, isPresented: $model.isPresentingWebView, userAgent: userAgent, onDismiss: { model.cancel() }) + .alert(String(localized: "Login Failed", comment: "Alert title"), isPresented: $model.isPresentingAlert) { + Button(role: .cancel) { + model.errorMessage = nil + } label: { + Text("OK", comment: "Button label for error alert dismissal.") + } + } message: { + Text(model.errorMessage ?? "?") + } + } +} + +#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!") + } +} + +#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!") + } +} + +#endif diff --git a/Sources/NextcloudKitUI/Views/Login/SharedAccountsView.swift b/Sources/NextcloudKitUI/Views/Login/SharedAccountsView.swift new file mode 100644 index 00000000..cf883837 --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Login/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/Login/WebView.swift b/Sources/NextcloudKitUI/Views/Login/WebView.swift new file mode 100644 index 00000000..4f3f4e5f --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Login/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/") + } +}