Consolidate onboarding into single login page with native styling and full localization

Merge the two-page welcome/login flow into a single page with the app icon,
title, subtitle, and login inputs all on one screen. Replace the custom blue
background and white-on-blue styling with native iOS system colors and
button styles. Add missing translations (de, es, fr) for all onboarding
strings and fix localization by using LocalizedStringKey and String(localized:).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 10:44:51 +01:00
parent ce2a814e5a
commit c8d9ab7397
4 changed files with 4178 additions and 3886 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -10,41 +10,59 @@ import OSLog
import SwiftUI import SwiftUI
struct OnboardingView: View { struct OnboardingView: View {
@State var selectedTab: Int = 0 @State var loginMethod: LoginMethod = .v2
var body: some View { // Login error alert
TabView(selection: $selectedTab) { @State var showAlert: Bool = false
WelcomeTab().tag(0) @State var alertMessage: String = String(localized: "Error: Could not connect to server.")
LoginTab().tag(1)
}
.tabViewStyle(.page)
.background(
selectedTab == 1 ? Color.nextcloudBlue.ignoresSafeArea() : Color(uiColor: .systemBackground).ignoresSafeArea()
)
.animation(.easeInOut, value: selectedTab)
}
}
struct WelcomeTab: View {
var body: some View { var body: some View {
VStack(alignment: .center) { ScrollView(showsIndicators: false) {
Spacer() VStack(spacing: 0) {
Image("cookbook-icon") Image("cookbook-icon")
.resizable() .resizable()
.frame(width: 120, height: 120) .frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 18))
Text("Thank you for downloading") .padding(.top, 48)
.font(.headline)
Text("Cookbook Client") Text("Cookbook Client")
.font(.largeTitle) .font(.largeTitle)
.bold() .bold()
Spacer() .padding(.top, 10)
Text("This application is an open source effort. If you're interested in suggesting or contributing new features, or you encounter any problems, please use the support link or visit the GitHub repository in the app settings.") Text("Thanks for downloading! Sign in to your Nextcloud server to get started.")
.padding() .font(.subheadline)
Spacer() .foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.padding(.top, 6)
VStack(alignment: .leading, spacing: 16) {
Picker("Login Method", selection: $loginMethod) {
Text("Nextcloud Login").tag(LoginMethod.v2)
Text("App Token Login").tag(LoginMethod.token)
}
.pickerStyle(.segmented)
if loginMethod == .token {
TokenLoginView(
showAlert: $showAlert,
alertMessage: $alertMessage
)
} else if loginMethod == .v2 {
V2LoginView(
showAlert: $showAlert,
alertMessage: $alertMessage
)
}
}
.padding(.top, 28)
} }
.padding()
.fontDesign(.rounded) .fontDesign(.rounded)
.padding()
.alert(alertMessage, isPresented: $showAlert) {
Button("Ok", role: .cancel) { }
}
}
.background(Color(uiColor: .systemGroupedBackground).ignoresSafeArea())
} }
} }
@@ -59,7 +77,7 @@ enum LoginMethod {
enum TokenLoginStage: LoginStage { enum TokenLoginStage: LoginStage {
case serverAddress, userName, appToken, validate case serverAddress, userName, appToken, validate
func next() -> TokenLoginStage { func next() -> TokenLoginStage {
switch self { switch self {
case .serverAddress: return .userName case .serverAddress: return .userName
@@ -68,7 +86,7 @@ enum TokenLoginStage: LoginStage {
case .validate: return .validate case .validate: return .validate
} }
} }
func previous() -> TokenLoginStage { func previous() -> TokenLoginStage {
switch self { switch self {
case .serverAddress: return .serverAddress case .serverAddress: return .serverAddress
@@ -79,160 +97,88 @@ enum TokenLoginStage: LoginStage {
} }
} }
struct LoginTab: View {
@State var loginMethod: LoginMethod = .v2
// Login error alert
@State var showAlert: Bool = false
@State var alertMessage: String = "Error: Could not connect to server."
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading) {
Spacer()
Picker("Login Method", selection: $loginMethod) {
Text("Nextcloud Login").tag(LoginMethod.v2)
Text("App Token Login").tag(LoginMethod.token)
}
.pickerStyle(.segmented)
.foregroundColor(.white)
.padding()
if loginMethod == .token {
TokenLoginView(
showAlert: $showAlert,
alertMessage: $alertMessage
)
}
else if loginMethod == .v2 {
V2LoginView(
showAlert: $showAlert,
alertMessage: $alertMessage
)
}
Spacer()
}
.fontDesign(.rounded)
.padding()
.alert(alertMessage, isPresented: $showAlert) {
Button("Ok", role: .cancel) { }
}
}
}
}
struct LoginLabel: View { struct LoginLabel: View {
let text: String let text: LocalizedStringKey
var body: some View { var body: some View {
Text(text) Text(text)
.foregroundColor(.white) .font(.subheadline)
.font(.headline) .foregroundStyle(.secondary)
.padding(.vertical, 5)
} }
} }
struct BorderedLoginTextField: View { struct BorderedLoginTextField: View {
var example: String var example: LocalizedStringKey
@Binding var text: String @Binding var text: String
@State var color: Color = .white
var body: some View { var body: some View {
TextField(example, text: $text) TextField(example, text: $text)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.foregroundColor(color)
.tint(color)
.padding() .padding()
.background( .background(Color(uiColor: .secondarySystemGroupedBackground))
RoundedRectangle(cornerRadius: 10) .clipShape(RoundedRectangle(cornerRadius: 10))
.stroke(.white, lineWidth: 2)
.foregroundColor(.clear)
)
} }
} }
struct LoginTextField: View { struct LoginTextField: View {
var example: String var example: LocalizedStringKey
@Binding var text: String @Binding var text: String
@State var color: Color = .white
var body: some View { var body: some View {
TextField(example, text: $text) TextField(example, text: $text)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.foregroundColor(color)
.tint(color)
.padding() .padding()
.background( .background(Color(uiColor: .secondarySystemGroupedBackground))
RoundedRectangle(cornerRadius: 10) .clipShape(RoundedRectangle(cornerRadius: 10))
.foregroundColor(Color.white.opacity(0.2))
)
} }
} }
struct ServerAddressField: View { struct ServerAddressField: View {
@ObservedObject var userSettings = UserSettings.shared @ObservedObject var userSettings = UserSettings.shared
@State var serverProtocol: ServerProtocol = UserSettings.shared.serverProtocol == ServerProtocol.http.rawValue ? ServerProtocol.http : ServerProtocol.https @State var serverProtocol: ServerProtocol = UserSettings.shared.serverProtocol == ServerProtocol.http.rawValue ? ServerProtocol.http : ServerProtocol.https
enum ServerProtocol: String { enum ServerProtocol: String {
case https="https://", http="http://" case https="https://", http="http://"
static let all = [https, http] static let all = [https, http]
} }
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading, spacing: 6) {
LoginLabel(text: "Server address") LoginLabel(text: "Server address")
VStack(alignment: .leading) { VStack(alignment: .leading, spacing: 10) {
HStack { HStack {
Picker(ServerProtocol.https.rawValue, selection: $serverProtocol) { Picker(ServerProtocol.https.rawValue, selection: $serverProtocol) {
ForEach(ServerProtocol.all, id: \.self) { ForEach(ServerProtocol.all, id: \.self) {
Text($0.rawValue) Text($0.rawValue)
} }
}.pickerStyle(.menu) }
.tint(.white) .pickerStyle(.menu)
.font(.headline) .tint(.accentColor)
.onChange(of: serverProtocol) { value in .onChange(of: serverProtocol) { value in
Logger.view.debug("\(value.rawValue)") Logger.view.debug("\(value.rawValue)")
userSettings.serverProtocol = value.rawValue userSettings.serverProtocol = value.rawValue
} }
TextField("e.g.: example.com", text: $userSettings.serverAddress) TextField("e.g.: example.com", text: $userSettings.serverAddress)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.foregroundStyle(.white) .padding(10)
.padding() .background(Color(uiColor: .secondarySystemGroupedBackground))
.background( .clipShape(RoundedRectangle(cornerRadius: 8))
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.white.opacity(0.2))
)
} }
LoginLabel(text: "Full server address")
.padding(.top)
Text(userSettings.serverProtocol + userSettings.serverAddress) Text(userSettings.serverProtocol + userSettings.serverAddress)
.foregroundColor(.white) .font(.footnote)
.padding(.vertical, 5) .foregroundStyle(.secondary)
} }
.padding() .padding()
.background( .background(Color(uiColor: .secondarySystemGroupedBackground))
RoundedRectangle(cornerRadius: 10) .clipShape(RoundedRectangle(cornerRadius: 12))
.stroke(.white, lineWidth: 2)
.foregroundColor(.clear)
)
} }
} }
} }
@@ -242,6 +188,5 @@ struct ServerAddressField_Preview: PreviewProvider {
ServerAddressField() ServerAddressField()
.previewLayout(.sizeThatFits) .previewLayout(.sizeThatFits)
.padding() .padding()
.background(Color.nextcloudBlue)
} }
} }

View File

@@ -15,56 +15,55 @@ struct TokenLoginView: View {
@Binding var showAlert: Bool @Binding var showAlert: Bool
@Binding var alertMessage: String @Binding var alertMessage: String
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
@State var userSettings = UserSettings.shared @State var userSettings = UserSettings.shared
// TextField handling // TextField handling
enum Field { enum Field {
case server case server
case username case username
case token case token
} }
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading, spacing: 16) {
ServerAddressField() ServerAddressField()
.padding(.bottom)
VStack(alignment: .leading, spacing: 6) {
LoginLabel(text: "User name") LoginLabel(text: "User name")
BorderedLoginTextField(example: "username", text: $userSettings.username) BorderedLoginTextField(example: "username", text: $userSettings.username)
.focused($focusedField, equals: .username) .focused($focusedField, equals: .username)
.textContentType(.username) .textContentType(.username)
.submitLabel(.next) .submitLabel(.next)
.padding(.bottom)
LoginLabel(text: "App Token")
BorderedLoginTextField(example: "can be generated in security settings of your nextcloud", text: $userSettings.token)
.focused($focusedField, equals: .token)
.textContentType(.password)
.submitLabel(.join)
HStack{
Spacer()
Button {
Task {
if await loginCheck(nextcloudLogin: false) {
userSettings.onboarding = false
}
}
} label: {
Text("Submit")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
}
.padding()
Spacer()
} }
VStack(alignment: .leading, spacing: 6) {
LoginLabel(text: "App Token")
BorderedLoginTextField(example: "can be generated in security settings of your nextcloud", text: $userSettings.token)
.focused($focusedField, equals: .token)
.textContentType(.password)
.submitLabel(.join)
}
Button {
Task {
if await loginCheck(nextcloudLogin: false) {
userSettings.onboarding = false
}
}
} label: {
Label("Submit", systemImage: "person.badge.key")
.font(.subheadline)
.fontWeight(.medium)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.foregroundStyle(Color.nextcloudBlue)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.nextcloudBlue.opacity(0.1))
)
}
.padding(.top, 4)
} }
.onSubmit { .onSubmit {
switch focusedField { switch focusedField {
@@ -77,14 +76,14 @@ struct TokenLoginView: View {
} }
} }
} }
func loginCheck(nextcloudLogin: Bool) async -> Bool { func loginCheck(nextcloudLogin: Bool) async -> Bool {
if userSettings.serverAddress == "" { if userSettings.serverAddress == "" {
alertMessage = "Please enter a server address!" alertMessage = String(localized: "Please enter a server address!")
showAlert = true showAlert = true
return false return false
} else if !nextcloudLogin && (userSettings.username == "" || userSettings.token == "") { } else if !nextcloudLogin && (userSettings.username == "" || userSettings.token == "") {
alertMessage = "Please enter a user name and app token!" alertMessage = String(localized: "Please enter a user name and app token!")
showAlert = true showAlert = true
return false return false
} }
@@ -95,7 +94,7 @@ struct TokenLoginView: View {
let _ = try await client.getCategories() let _ = try await client.getCategories()
return true return true
} catch { } catch {
alertMessage = "Login failed. Please check your inputs and internet connection." alertMessage = String(localized: "Login failed. Please check your inputs and internet connection.")
showAlert = true showAlert = true
return false return false
} }

View File

@@ -12,14 +12,14 @@ import WebKit
enum V2LoginStage: LoginStage { enum V2LoginStage: LoginStage {
case login, validate case login, validate
func next() -> V2LoginStage { func next() -> V2LoginStage {
switch self { switch self {
case .login: return .validate case .login: return .validate
case .validate: return .validate case .validate: return .validate
} }
} }
func previous() -> V2LoginStage { func previous() -> V2LoginStage {
switch self { switch self {
case .login: return .login case .login: return .login
@@ -33,105 +33,101 @@ enum V2LoginStage: LoginStage {
struct V2LoginView: View { struct V2LoginView: View {
@Binding var showAlert: Bool @Binding var showAlert: Bool
@Binding var alertMessage: String @Binding var alertMessage: String
@State var loginStage: V2LoginStage = .login @State var loginStage: V2LoginStage = .login
@State var loginRequest: LoginV2Request? = nil @State var loginRequest: LoginV2Request? = nil
@State var presentBrowser = false @State var presentBrowser = false
// TextField handling // TextField handling
enum Field { enum Field {
case server case server
case username case username
case token case token
} }
var body: some View { var body: some View {
ScrollView { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading) { ServerAddressField()
ServerAddressField()
CollapsibleView { CollapsibleView(titleColor: .secondary) {
VStack(alignment: .leading) { VStack(alignment: .leading, spacing: 8) {
Text("Make sure to enter the server address in the form 'example.com', or \n'<server address>:<port>'\n when a non-standard port is used.") Text("Make sure to enter the server address in the form 'example.com', or '<server address>:<port>' when a non-standard port is used.")
.padding(.bottom) Text("The 'Login' button will open a web browser. Please follow the login instructions provided there. After a successful login, return to this application and press 'Validate'.")
Text("The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'.") Text("If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually.")
.padding(.bottom)
Text("If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually.")
}
} title: {
Text("Show help")
.foregroundColor(.white)
.font(.headline)
}.padding()
if loginRequest != nil {
Button("Copy Link") {
UIPasteboard.general.string = loginRequest!.login
}
.font(.headline)
.foregroundStyle(.white)
.padding()
} }
.font(.footnote)
HStack { .foregroundStyle(.secondary)
Button { } title: {
if UserSettings.shared.serverAddress == "" { Text("Show help")
alertMessage = "Please enter a valid server address." .font(.subheadline)
showAlert = true }
return
} if loginRequest != nil {
Button {
Task { UIPasteboard.general.string = loginRequest!.login
let error = await sendLoginV2Request() } label: {
if let error = error { Label("Copy Link", systemImage: "doc.on.doc")
alertMessage = "A network error occured (\(error.localizedDescription))." .font(.subheadline)
showAlert = true
}
if let loginRequest = loginRequest {
presentBrowser = true
//await UIApplication.shared.open(URL(string: loginRequest.login)!)
} else {
alertMessage = "Unable to reach server. Please check your server address and internet connection."
showAlert = true
}
}
loginStage = loginStage.next()
} label: {
Text("Login")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
}.padding()
if loginStage == .validate {
Spacer()
Button {
// fetch login v2 response
Task {
let (response, error) = await fetchLoginV2Response()
checkLogin(response: response, error: error)
}
} label: {
Text("Validate")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
}
.disabled(loginRequest == nil ? true : false)
.padding()
}
} }
} }
HStack(spacing: 12) {
Button {
if UserSettings.shared.serverAddress == "" {
alertMessage = String(localized: "Please enter a valid server address.")
showAlert = true
return
}
Task {
let error = await sendLoginV2Request()
if let error = error {
alertMessage = String(localized: "A network error occurred. Please try again.")
showAlert = true
}
if let _ = loginRequest {
presentBrowser = true
} else {
alertMessage = String(localized: "Unable to reach server. Please check your server address and internet connection.")
showAlert = true
}
}
loginStage = loginStage.next()
} label: {
Label("Login", systemImage: "person.badge.key")
.font(.subheadline)
.fontWeight(.medium)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.foregroundStyle(Color.nextcloudBlue)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.nextcloudBlue.opacity(0.1))
)
}
if loginStage == .validate {
Button {
Task {
let (response, error) = await fetchLoginV2Response()
checkLogin(response: response, error: error)
}
} label: {
Label("Validate", systemImage: "checkmark.circle.fill")
.font(.subheadline)
.fontWeight(.medium)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.foregroundStyle(Color.nextcloudBlue)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.nextcloudBlue.opacity(0.1))
)
}
.disabled(loginRequest == nil)
}
}
.padding(.top, 4)
} }
.sheet(isPresented: $presentBrowser, onDismiss: { .sheet(isPresented: $presentBrowser, onDismiss: {
Task { Task {
@@ -144,26 +140,26 @@ struct V2LoginView: View {
} }
} }
} }
func sendLoginV2Request() async -> NetworkError? { func sendLoginV2Request() async -> NetworkError? {
let (req, error) = await NextcloudApi.loginV2Request() let (req, error) = await NextcloudApi.loginV2Request()
self.loginRequest = req self.loginRequest = req
return error return error
} }
func fetchLoginV2Response() async -> (LoginV2Response?, NetworkError?) { func fetchLoginV2Response() async -> (LoginV2Response?, NetworkError?) {
guard let loginRequest = loginRequest else { return (nil, .invalidRequest) } guard let loginRequest = loginRequest else { return (nil, .invalidRequest) }
return await NextcloudApi.loginV2Response(req: loginRequest) return await NextcloudApi.loginV2Response(req: loginRequest)
} }
func checkLogin(response: LoginV2Response?, error: NetworkError?) { func checkLogin(response: LoginV2Response?, error: NetworkError?) {
if let error = error { if let error = error {
alertMessage = "Login failed. Please login via the browser and try again. (\(error.localizedDescription))" alertMessage = String(localized: "Login failed. Please login via the browser and try again.")
showAlert = true showAlert = true
return return
} }
guard let response = response else { guard let response = response else {
alertMessage = "Login failed. Please login via the browser and try again." alertMessage = String(localized: "Login failed. Please login via the browser and try again.")
showAlert = true showAlert = true
return return
} }