diff --git a/Twigs.xcodeproj/project.pbxproj b/Twigs.xcodeproj/project.pbxproj index b68f71f..57af5e0 100644 --- a/Twigs.xcodeproj/project.pbxproj +++ b/Twigs.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ 28FE6B0623444A9800D5543E /* TransactionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6B0523444A9800D5543E /* TransactionDetailsView.swift */; }; 8005FD5D277EAB0200E48B23 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8005FD5C277EAB0200E48B23 /* MainView.swift */; }; 800DFC2C277FF47A00EDCE9B /* AsyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800DFC2B277FF47A00EDCE9B /* AsyncData.swift */; }; + 80166EB5284BE98300B3AE06 /* EditUsernameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80166EB4284BE98300B3AE06 /* EditUsernameView.swift */; }; 801D08CC275ECEFA00931465 /* RecurringTransactionsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */; }; 801D08CE275F189E00931465 /* RecurringTransactionsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */; }; 801D08D2275FB7DE00931465 /* RecurringTransactionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08D1275FB7DE00931465 /* RecurringTransactionDetailsView.swift */; }; @@ -55,6 +56,8 @@ 809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */; }; 80A419ED2787C0A00090C515 /* TwigsCli.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A419EC2787C0A00090C515 /* TwigsCli.swift */; }; 80A419F52787C1520090C515 /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = 80A419F42787C1520090C515 /* ArgumentParser */; }; + 80AC75CD284E8E100099E846 /* EditPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80AC75CC284E8E100099E846 /* EditPasswordView.swift */; }; + 80AC75CF284E8E1B0099E846 /* EditEmailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80AC75CE284E8E1B0099E846 /* EditEmailView.swift */; }; 80AF7A982835ED3B009565C6 /* RecurringTransactionFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80AF7A972835ED3B009565C6 /* RecurringTransactionFormView.swift */; }; 80D1FC14277C1EF9007F17FB /* InlineLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D1FC13277C1EF9007F17FB /* InlineLoadingView.swift */; }; 80D2CE1A2833448500EDD6C2 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D2CE192833448500EDD6C2 /* DataStore.swift */; }; @@ -127,6 +130,7 @@ 28FE6B0523444A9800D5543E /* TransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDetailsView.swift; sourceTree = ""; }; 8005FD5C277EAB0200E48B23 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; 800DFC2B277FF47A00EDCE9B /* AsyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncData.swift; sourceTree = ""; }; + 80166EB4284BE98300B3AE06 /* EditUsernameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditUsernameView.swift; sourceTree = ""; }; 801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionsListView.swift; sourceTree = ""; }; 801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionsRepository.swift; sourceTree = ""; }; 801D08D1275FB7DE00931465 /* RecurringTransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionDetailsView.swift; sourceTree = ""; }; @@ -147,6 +151,8 @@ 809B94242722597800B1DAE2 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 80A419EA2787C0A00090C515 /* twigs-cli */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "twigs-cli"; sourceTree = BUILT_PRODUCTS_DIR; }; 80A419EC2787C0A00090C515 /* TwigsCli.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsCli.swift; sourceTree = ""; }; + 80AC75CC284E8E100099E846 /* EditPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPasswordView.swift; sourceTree = ""; }; + 80AC75CE284E8E1B0099E846 /* EditEmailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditEmailView.swift; sourceTree = ""; }; 80AF7A972835ED3B009565C6 /* RecurringTransactionFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionFormView.swift; sourceTree = ""; }; 80D1FC13277C1EF9007F17FB /* InlineLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineLoadingView.swift; sourceTree = ""; }; 80D2CE192833448500EDD6C2 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = ""; }; @@ -196,6 +202,9 @@ isa = PBXGroup; children = ( 282126A0235929B800072D52 /* ProfileView.swift */, + 80166EB4284BE98300B3AE06 /* EditUsernameView.swift */, + 80AC75CC284E8E100099E846 /* EditPasswordView.swift */, + 80AC75CE284E8E1B0099E846 /* EditEmailView.swift */, ); path = Profile; sourceTree = ""; @@ -562,11 +571,13 @@ 2841022723419A2B00EAFA29 /* TabbedBudgetView.swift in Sources */, 801D08D2275FB7DE00931465 /* RecurringTransactionDetailsView.swift in Sources */, 28FE6B0623444A9800D5543E /* TransactionDetailsView.swift in Sources */, + 80AC75CF284E8E1B0099E846 /* EditEmailView.swift in Sources */, 80D1FC14277C1EF9007F17FB /* InlineLoadingView.swift in Sources */, 28AC952C233C434800BFB70A /* UserRepository.swift in Sources */, 28FE6B04234449DC00D5543E /* TransactionListView.swift in Sources */, 28AC94F2233C373900BFB70A /* LoginView.swift in Sources */, 80FC1BC2284146CD00682F21 /* MonthlyFrequencyPicker.swift in Sources */, + 80AC75CD284E8E100099E846 /* EditPasswordView.swift in Sources */, 802161D0277647920075761A /* AsyncObservableObject.swift in Sources */, 282126BB235CDD3C00072D52 /* BudgetDetailsView.swift in Sources */, 80D2CE1A2833448500EDD6C2 /* DataStore.swift in Sources */, @@ -589,6 +600,7 @@ 807FEAB52837F71200D05338 /* RecurringTransactionForm.swift in Sources */, 80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */, 8044BA3927828E9D009A78D4 /* CategoryDataStore.swift in Sources */, + 80166EB5284BE98300B3AE06 /* EditUsernameView.swift in Sources */, 284102302342D97300EAFA29 /* BudgetListsView.swift in Sources */, 282126BD235CDE1400072D52 /* ProgressView.swift in Sources */, 806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */, diff --git a/Twigs/DataStore.swift b/Twigs/DataStore.swift index 4340530..8e985e7 100644 --- a/Twigs/DataStore.swift +++ b/Twigs/DataStore.swift @@ -476,10 +476,11 @@ class DataStore : ObservableObject { @Published var currentUser: AsyncData = .empty { didSet { - if case .success(_) = self.currentUser { - self.showLogin = false - } else { + switch currentUser { + case .empty, .loading: self.showLogin = true + default: + self.showLogin = false } } } @@ -578,6 +579,72 @@ class DataStore : ObservableObject { self.currentUser = .error(error) } } + + func updateUsername(_ username: String) async -> UsernameError? { + guard case let .success(current) = self.currentUser else { + return .unknown + } + self.currentUser = .saving(current) + do { + let updated = try await self.apiService.updateUser(current.copy(username: username)) + self.currentUser = .success(updated) + return nil + } catch { + self.currentUser = .error(error, current) + return .unavailable + } + } + + func updateEmail(_ email: String) async -> EmailError? { + guard case let .success(current) = self.currentUser else { + return .unknown + } + if !email.isEmpty && (!email.contains("@") || !email.contains(".")) { + return .invalid + } + self.currentUser = .saving(current) + do { + let updated = try await self.apiService.updateUser(current.copy(email: email)) + self.currentUser = .success(updated) + return nil + } catch { + self.currentUser = .error(error, current) + return .unavailable + } + } + + func updatePassword(_ password: String, confirmPassword: String) async -> PasswordError? { + guard case let .success(current) = self.currentUser else { + return .unknown + } + if password != confirmPassword { + return .notMatching + } + self.currentUser = .saving(current) + do { + let updated = try await self.apiService.updateUser(current.copy(password: password)) + self.currentUser = .success(updated) + return nil + } catch { + self.currentUser = .error(error, current) + return .unknown + } + } + + func saveUser(_ user: User) async -> Bool { + guard case let .success(current) = self.currentUser else { + return false + } + self.currentUser = .saving(current) + do { + let updated = try await self.apiService.updateUser(user) + self.currentUser = .success(updated) + return true + } catch { + self.currentUser = .error(error, current) + return false + } + } @Published var user: AsyncData = .empty @@ -591,6 +658,24 @@ class DataStore : ObservableObject { } } +enum UsernameError: String, Error { + case empty = "cannot_be_empty" + case unavailable = "username_taken" + case unknown = "unknown_error" +} + +enum EmailError: String, Error { + case invalid = "email_invalid" + case unavailable = "email_taken" + case unknown = "unknown_error" +} + +enum PasswordError: String, Error { + case empty = "cannot_be_empty" + case notMatching = "passwords_dont_match" + case unknown = "unknown_error" +} + enum UserStatus: Error, Equatable { case unauthenticated case authenticating diff --git a/Twigs/Profile/EditEmailView.swift b/Twigs/Profile/EditEmailView.swift new file mode 100644 index 0000000..3220b03 --- /dev/null +++ b/Twigs/Profile/EditEmailView.swift @@ -0,0 +1,53 @@ +// +// EditEmailView.swift +// Twigs +// +// Created by William Brawner on 6/6/22. +// Copyright © 2022 William Brawner. All rights reserved. +// + +import SwiftUI + +struct EditEmailView: View { + @EnvironmentObject var dataStore: DataStore + @Environment(\.dismiss) var dismiss + @State var email: String + @State var error: EmailError? = nil + + @ViewBuilder + var body: some View { + if case .saving(_) = dataStore.currentUser { + ActivityIndicator(isAnimating: .constant(true), style: .large) + } else { + VStack(alignment: .leading, spacing: 4.0) { + Form { + Section(content: { + TextField("prompt_email", text: $email) + .textContentType(.emailAddress) + .textInputAutocapitalization(.never) + }, footer: { + if let error = self.error { + Text(LocalizedStringKey(error.rawValue)) + .foregroundColor(.red) + } + }) + Button("save", action: { + Task { + self.error = await dataStore.updateEmail(email) + if self.error == nil { + dismiss() + } + } + }) + } + } + } + } +} + +struct EditEmailView_Previews: PreviewProvider { + static var previews: some View { + EditEmailView(email: "me@example.com") + .environmentObject(DataStore(TwigsInMemoryCacheService())) + } +} diff --git a/Twigs/Profile/EditPasswordView.swift b/Twigs/Profile/EditPasswordView.swift new file mode 100644 index 0000000..156f65c --- /dev/null +++ b/Twigs/Profile/EditPasswordView.swift @@ -0,0 +1,55 @@ +// +// EditPasswordView.swift +// Twigs +// +// Created by William Brawner on 6/6/22. +// Copyright © 2022 William Brawner. All rights reserved. +// + +import SwiftUI + +struct EditPasswordView: View { + @Environment(\.dismiss) var dismiss + @EnvironmentObject var dataStore: DataStore + @State var password: String = "" + @State var confirmPassword: String = "" + @State var error: PasswordError? = nil + + @ViewBuilder + var body: some View { + if case .saving(_) = dataStore.currentUser { + ActivityIndicator(isAnimating: .constant(true), style: .large) + } else { + VStack(alignment: .leading, spacing: 4.0) { + if case .notMatching = self.error { + Text("passwords_must_match") + .foregroundColor(.red) + .multilineTextAlignment(.center) + } + Form { + Section { + SecureField("prompt_password", text: $password) + .textContentType(.newPassword) + SecureField("prompt_confirm_password", text: $confirmPassword) + .textContentType(.newPassword) + } + Button("save", action: { + Task { + self.error = await dataStore.updatePassword(password, confirmPassword: confirmPassword) + if self.error == nil { + dismiss() + } + } + }) + } + } + } + } +} + +struct EditPasswordView_Previews: PreviewProvider { + static var previews: some View { + EditPasswordView() + .environmentObject(DataStore(TwigsInMemoryCacheService())) + } +} diff --git a/Twigs/Profile/EditUsernameView.swift b/Twigs/Profile/EditUsernameView.swift new file mode 100644 index 0000000..e854cb2 --- /dev/null +++ b/Twigs/Profile/EditUsernameView.swift @@ -0,0 +1,53 @@ +// +// EditUsernameView.swift +// Twigs +// +// Created by William Brawner on 6/4/22. +// Copyright © 2022 William Brawner. All rights reserved. +// + +import SwiftUI +import TwigsCore + +struct EditUsernameView: View { + @EnvironmentObject var dataStore: DataStore + @Environment(\.dismiss) var dismiss + @State var username: String + @State var error: UsernameError? = nil + + @ViewBuilder + var body: some View { + if case .saving(_) = dataStore.currentUser { + ActivityIndicator(isAnimating: .constant(true), style: .large) + } else { + VStack(alignment: .leading, spacing: 4.0) { + if case .unavailable = self.error { + Text("username_unavailable") + .foregroundColor(.red) + .multilineTextAlignment(.center) + } + Form { + Section("prompt_username") { + TextField("prompt_username", text: $username) + .textContentType(.username) + } + Button("save", action: { + Task { + self.error = await dataStore.updateUsername(username) + if self.error == nil { + dismiss() + } + } + }) + } + } + } + } +} + +struct EditUsernameView_Previews: PreviewProvider { + static var previews: some View { + EditUsernameView(username: "username") + .environmentObject(DataStore(TwigsInMemoryCacheService())) + } +} diff --git a/Twigs/Profile/ProfileView.swift b/Twigs/Profile/ProfileView.swift index 47de6f4..44d601d 100644 --- a/Twigs/Profile/ProfileView.swift +++ b/Twigs/Profile/ProfileView.swift @@ -11,30 +11,77 @@ import TwigsCore struct ProfileView: View { @EnvironmentObject var dataStore: DataStore + var username: String { + if case let .success(user) = self.dataStore.currentUser { + return user.username + } else { + return "" + } + } + var email: String { + if case let .success(user) = self.dataStore.currentUser { + return user.email ?? "" + } else { + return "" + } + } @ViewBuilder var body: some View { - VStack(spacing: 10) { - Image(systemName: "person.circle.fill") - .resizable() - .frame(width: 100, height: 100, alignment: .center) - .scaledToFill() - .clipShape(Circle()) - .overlay(Circle().stroke(Color.white, lineWidth: 4)) - .shadow(radius: 5) - if case let .success(user) = self.dataStore.currentUser { - Text(user.username) + List { + Section(content: { + NavigationLink( + destination: EditUsernameView(username: username) + .navigationTitle("change_username") + ) { + Text("change_username") + } +// NavigationLink(destination: EmptyView()) { +// Text("change_profile_picture") +// } + }, header: { + HStack { + Spacer() + VStack(alignment: .center, spacing: 10.0) { + Image(systemName: "person.circle.fill") + .resizable() + .frame(width: 100, height: 100, alignment: .center) + .scaledToFill() + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white, lineWidth: 1)) + .shadow(radius: 4) + Spacer() + Text(username) + } + Spacer() + } + }) + Section { + NavigationLink( + destination: EditPasswordView() + .navigationTitle("change_password") + ) { + Text("change_password") + } + NavigationLink( + destination: EditEmailView(email: email) + .navigationTitle("change_email") + ) { + Text("change_email") + } } - NavigationLink(destination: EmptyView()) { - Text("change_password") - } - NavigationLink(destination: EmptyView()) { - Text("change_email") - } - NavigationLink(destination: EmptyView()) { - Text("delete_account") - .foregroundColor(.red) + Section { + Button("logout", action: { + // TODO: Show some dialog to confirm + dataStore.logout() + }) } +// Section { +// NavigationLink(destination: EmptyView()) { +// Text("delete_account") +// .foregroundColor(.red) +// } +// } } } } @@ -43,6 +90,7 @@ struct ProfileView: View { struct ProfileView_Previews: PreviewProvider { static var previews: some View { ProfileView() + .environmentObject(DataStore(TwigsInMemoryCacheService())) } } #endif diff --git a/Twigs/TabbedBudgetView.swift b/Twigs/TabbedBudgetView.swift index 9e77acb..9939c52 100644 --- a/Twigs/TabbedBudgetView.swift +++ b/Twigs/TabbedBudgetView.swift @@ -66,6 +66,16 @@ struct TabbedBudgetView: View { } .tag(3) .keyboardShortcut("4") + NavigationView { + ProfileView() + .navigationBarTitle("profile") + } + .tabItem { + Image(systemName: "person.circle.fill") + Text("profile") + } + .tag(4) + .keyboardShortcut("5") } default: ActivityIndicator(isAnimating: .constant(true), style: .large) @@ -104,9 +114,13 @@ struct TabbedBudgetView: View { } -// -//struct TabbedBudgetView_Previews: PreviewProvider { -// static var previews: some View { -// TabbedBudgetView() -// } -//} + +struct TabbedBudgetView_Previews: PreviewProvider { + @StateObject static var apiService = TwigsInMemoryCacheService() + + static var previews: some View { + TabbedBudgetView() + .environmentObject(DataStore(apiService)) + .environmentObject(apiService) + } +} diff --git a/Twigs/User/UserRepository.swift b/Twigs/User/UserRepository.swift index 890c479..fd28b23 100644 --- a/Twigs/User/UserRepository.swift +++ b/Twigs/User/UserRepository.swift @@ -13,7 +13,7 @@ import TwigsCore #if DEBUG class MockUserRepository: UserRepository { static let loginResponse = LoginResponse(token: "token", expiration: "2020-01-01T12:00:00Z", userId: "0") - static let currentUser = User(id: "0", username: "root", email: "root@localhost", avatar: nil) + static let currentUser = User(id: "0", username: "root", email: "root@localhost", password: nil, avatar: nil) static var token: String? = nil func setToken(_ token: String) { diff --git a/Twigs/en.lproj/Localizable.strings b/Twigs/en.lproj/Localizable.strings index 49f3a08..9df2660 100644 --- a/Twigs/en.lproj/Localizable.strings +++ b/Twigs/en.lproj/Localizable.strings @@ -73,6 +73,8 @@ // MARK: Profile "profile" = "Profile"; +"change_username" = "Change Username"; +"change_profile_picture" = "Change Profile Picture"; "change_password" = "Change Password"; "change_email" = "Change Email"; "delete_account" = "Delete Account"; @@ -131,3 +133,10 @@ "end" = "End"; "never" = "Never"; "onDate" = "On Date"; + +"email_invalid" = "Invalid email address"; +"cannot_be_empty" = "This cannot be empty"; +"username_taken" = "Username taken"; +"email_taken" = "Email taken"; +"passwords_dont_match" = "Password don't match" +"unknown_error" = "An unknown error has occurred"; diff --git a/Twigs/es.lproj/Localizable.strings b/Twigs/es.lproj/Localizable.strings index fe7f8e5..dafecaa 100644 --- a/Twigs/es.lproj/Localizable.strings +++ b/Twigs/es.lproj/Localizable.strings @@ -74,6 +74,8 @@ // MARK: Profile "profile" = "Perfil"; +"change_username" = "Cambiar Nombre de Usuario"; +"change_profile_picture" = "Cambiar Foto de Perfíl"; "change_password" = "Cambiar Contraseña"; "change_email" = "Cambiar Correo"; "delete_account" = "Eliminar Cuenta"; @@ -132,3 +134,10 @@ "end" = "Fin "; "never" = "Nunca"; "onDate" = "Fecha"; + +"email_invalid" = "Correo electrónico inválido"; +"cannot_be_empty" = "No se puede omitir"; +"username_taken" = "Nombre de usuario no disponible"; +"email_taken" = "Correo electrónico no disponible"; +"passwords_dont_match" = "Contraseñas no coinciden" +"unknown_error" = "Se ocurrió un error";