Implement basic profile editing

This commit is contained in:
William Brawner 2022-06-06 18:11:32 -06:00
parent a61cc567fe
commit d72be0abd9
10 changed files with 367 additions and 29 deletions

View file

@ -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 = "<group>"; };
8005FD5C277EAB0200E48B23 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
800DFC2B277FF47A00EDCE9B /* AsyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncData.swift; sourceTree = "<group>"; };
80166EB4284BE98300B3AE06 /* EditUsernameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditUsernameView.swift; sourceTree = "<group>"; };
801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionsListView.swift; sourceTree = "<group>"; };
801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionsRepository.swift; sourceTree = "<group>"; };
801D08D1275FB7DE00931465 /* RecurringTransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionDetailsView.swift; sourceTree = "<group>"; };
@ -147,6 +151,8 @@
809B94242722597800B1DAE2 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
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 = "<group>"; };
80AC75CC284E8E100099E846 /* EditPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPasswordView.swift; sourceTree = "<group>"; };
80AC75CE284E8E1B0099E846 /* EditEmailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditEmailView.swift; sourceTree = "<group>"; };
80AF7A972835ED3B009565C6 /* RecurringTransactionFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionFormView.swift; sourceTree = "<group>"; };
80D1FC13277C1EF9007F17FB /* InlineLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineLoadingView.swift; sourceTree = "<group>"; };
80D2CE192833448500EDD6C2 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
@ -196,6 +202,9 @@
isa = PBXGroup;
children = (
282126A0235929B800072D52 /* ProfileView.swift */,
80166EB4284BE98300B3AE06 /* EditUsernameView.swift */,
80AC75CC284E8E100099E846 /* EditPasswordView.swift */,
80AC75CE284E8E1B0099E846 /* EditEmailView.swift */,
);
path = Profile;
sourceTree = "<group>";
@ -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 */,

View file

@ -476,10 +476,11 @@ class DataStore : ObservableObject {
@Published var currentUser: AsyncData<User> = .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<User> = .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

View file

@ -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()))
}
}

View file

@ -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()))
}
}

View file

@ -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()))
}
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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) {

View file

@ -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";

View file

@ -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";