From e621f659c8d53a199696929be785141ff4b0a4e6 Mon Sep 17 00:00:00 2001 From: Billy Brawner Date: Sat, 28 May 2022 22:25:12 -0600 Subject: [PATCH] Implement creation/editing of recurring transactions --- Twigs.xcodeproj/project.pbxproj | 24 +++ Twigs/DataStore.swift | 70 +++++++- .../RecurringTransactionDetailsView.swift | 13 +- .../RecurringTransactionForm.swift | 156 ++++++++++++++++++ .../RecurringTransactionFormView.swift | 144 ++++++++++++++++ .../RecurringTransactionsListView.swift | 23 +++ .../RecurringTransactionsRepository.swift | 2 +- Twigs/Transaction/TransactionFormSheet.swift | 24 +-- Twigs/Views/MonthlyFrequencyPicker.swift | 124 ++++++++++++++ Twigs/Views/MultiPicker.swift | 65 ++++++++ Twigs/Views/WeeklyFrequencyPicker.swift | 62 +++++++ Twigs/Views/YearlyFrequencyPicker.swift | 118 +++++++++++++ Twigs/en.lproj/Localizable.strings | 51 ++++++ Twigs/es.lproj/Localizable.strings | 51 ++++++ 14 files changed, 905 insertions(+), 22 deletions(-) create mode 100644 Twigs/Recurring Transactions/RecurringTransactionForm.swift create mode 100644 Twigs/Recurring Transactions/RecurringTransactionFormView.swift create mode 100644 Twigs/Views/MonthlyFrequencyPicker.swift create mode 100644 Twigs/Views/MultiPicker.swift create mode 100644 Twigs/Views/WeeklyFrequencyPicker.swift create mode 100644 Twigs/Views/YearlyFrequencyPicker.swift diff --git a/Twigs.xcodeproj/project.pbxproj b/Twigs.xcodeproj/project.pbxproj index fe3f924..b68f71f 100644 --- a/Twigs.xcodeproj/project.pbxproj +++ b/Twigs.xcodeproj/project.pbxproj @@ -47,14 +47,20 @@ 806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806C784F272B700B00FA1375 /* TwigsApp.swift */; }; 8076A84F2809FE8E006B9DC9 /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = 8076A84E2809FE8E006B9DC9 /* ArgumentParser */; }; 8076A8522809FE99006B9DC9 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 8076A8512809FE99006B9DC9 /* Collections */; }; + 807FEAB52837F71200D05338 /* RecurringTransactionForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807FEAB42837F71200D05338 /* RecurringTransactionForm.swift */; }; + 807FEAB72838042500D05338 /* MultiPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807FEAB62838042500D05338 /* MultiPicker.swift */; }; 80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80820144275FFD380040996E /* SidebarBudgetView.swift */; }; 808CA1A728354005002EDD59 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 808CA1A628354005002EDD59 /* XCTest.framework */; }; 808CA1A928355B30002EDD59 /* BudgetFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808CA1A828355B30002EDD59 /* BudgetFormView.swift */; }; 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 */; }; + 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 */; }; + 80FC1BBE28411DD800682F21 /* YearlyFrequencyPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80FC1BBD28411DD800682F21 /* YearlyFrequencyPicker.swift */; }; + 80FC1BC0284146A000682F21 /* WeeklyFrequencyPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80FC1BBF284146A000682F21 /* WeeklyFrequencyPicker.swift */; }; + 80FC1BC2284146CD00682F21 /* MonthlyFrequencyPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80FC1BC1284146CD00682F21 /* MonthlyFrequencyPicker.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -132,6 +138,8 @@ 8044BA3C2784CC0D009A78D4 /* TransactionForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionForm.swift; sourceTree = ""; }; 8044BA3E27853054009A78D4 /* CategoryForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryForm.swift; sourceTree = ""; }; 806C784F272B700B00FA1375 /* TwigsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsApp.swift; sourceTree = ""; }; + 807FEAB42837F71200D05338 /* RecurringTransactionForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionForm.swift; sourceTree = ""; }; + 807FEAB62838042500D05338 /* MultiPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiPicker.swift; sourceTree = ""; }; 80820144275FFD380040996E /* SidebarBudgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarBudgetView.swift; sourceTree = ""; }; 808CA1A628354005002EDD59 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; 808CA1A828355B30002EDD59 /* BudgetFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetFormView.swift; sourceTree = ""; }; @@ -139,8 +147,12 @@ 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 = ""; }; + 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 = ""; }; + 80FC1BBD28411DD800682F21 /* YearlyFrequencyPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YearlyFrequencyPicker.swift; sourceTree = ""; }; + 80FC1BBF284146A000682F21 /* WeeklyFrequencyPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeeklyFrequencyPicker.swift; sourceTree = ""; }; + 80FC1BC1284146CD00682F21 /* MonthlyFrequencyPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonthlyFrequencyPicker.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -219,6 +231,10 @@ 282126BC235CDE1400072D52 /* ProgressView.swift */, 80D1FC13277C1EF9007F17FB /* InlineLoadingView.swift */, 8005FD5C277EAB0200E48B23 /* MainView.swift */, + 807FEAB62838042500D05338 /* MultiPicker.swift */, + 80FC1BBD28411DD800682F21 /* YearlyFrequencyPicker.swift */, + 80FC1BBF284146A000682F21 /* WeeklyFrequencyPicker.swift */, + 80FC1BC1284146CD00682F21 /* MonthlyFrequencyPicker.swift */, ); path = Views; sourceTree = ""; @@ -355,6 +371,8 @@ 801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */, 801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */, 801D08D1275FB7DE00931465 /* RecurringTransactionDetailsView.swift */, + 80AF7A972835ED3B009565C6 /* RecurringTransactionFormView.swift */, + 807FEAB42837F71200D05338 /* RecurringTransactionForm.swift */, ); path = "Recurring Transactions"; sourceTree = ""; @@ -538,6 +556,7 @@ files = ( 2821266023555FD300072D52 /* TransactionFormSheet.swift in Sources */, 8044BA3D2784CC0D009A78D4 /* TransactionForm.swift in Sources */, + 80FC1BC0284146A000682F21 /* WeeklyFrequencyPicker.swift in Sources */, 28FE6AFC23441E4500D5543E /* CategoryRepository.swift in Sources */, 801D08CE275F189E00931465 /* RecurringTransactionsRepository.swift in Sources */, 2841022723419A2B00EAFA29 /* TabbedBudgetView.swift in Sources */, @@ -547,6 +566,7 @@ 28AC952C233C434800BFB70A /* UserRepository.swift in Sources */, 28FE6B04234449DC00D5543E /* TransactionListView.swift in Sources */, 28AC94F2233C373900BFB70A /* LoginView.swift in Sources */, + 80FC1BC2284146CD00682F21 /* MonthlyFrequencyPicker.swift in Sources */, 802161D0277647920075761A /* AsyncObservableObject.swift in Sources */, 282126BB235CDD3C00072D52 /* BudgetDetailsView.swift in Sources */, 80D2CE1A2833448500EDD6C2 /* DataStore.swift in Sources */, @@ -562,14 +582,18 @@ 282126A1235929B800072D52 /* ProfileView.swift in Sources */, 28AC9529233C433400BFB70A /* TransactionRepository.swift in Sources */, 809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */, + 807FEAB72838042500D05338 /* MultiPicker.swift in Sources */, 28FE6AF62342E4CC00D5543E /* BudgetRepository.swift in Sources */, + 80FC1BBE28411DD800682F21 /* YearlyFrequencyPicker.swift in Sources */, 8044BA3F27853054009A78D4 /* CategoryForm.swift in Sources */, + 807FEAB52837F71200D05338 /* RecurringTransactionForm.swift in Sources */, 80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */, 8044BA3927828E9D009A78D4 /* CategoryDataStore.swift in Sources */, 284102302342D97300EAFA29 /* BudgetListsView.swift in Sources */, 282126BD235CDE1400072D52 /* ProgressView.swift in Sources */, 806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */, 808CA1A928355B30002EDD59 /* BudgetFormView.swift in Sources */, + 80AF7A982835ED3B009565C6 /* RecurringTransactionFormView.swift in Sources */, 28CE8B9523525F990072BC4C /* Extensions.swift in Sources */, 801D08CC275ECEFA00931465 /* RecurringTransactionsListView.swift in Sources */, ); diff --git a/Twigs/DataStore.swift b/Twigs/DataStore.swift index 5ea1fc4..4340530 100644 --- a/Twigs/DataStore.swift +++ b/Twigs/DataStore.swift @@ -33,6 +33,37 @@ class DataStore : ObservableObject { @Published var showBudgetSelection: Bool = true @Published var editingBudget: Bool = false @Published var editingCategory: Bool = false + @Published var editingRecurringTransaction: Bool = false + + var currentUserId: String? { + get { + if case let .success(currentUser) = self.currentUser { + return currentUser.id + } else { + return nil + } + } + } + + var budgetId: String? { + get { + if case let .success(budget) = self.budget { + return budget.id + } else { + return nil + } + } + } + + var categoryId: String? { + get { + if case let .success(category) = self.category { + return category.id + } else { + return nil + } + } + } init( _ apiService: TwigsApiService @@ -261,8 +292,12 @@ class DataStore : ObservableObject { didSet { if case let .success(transaction) = self.recurringTransaction { self.selectedRecurringTransaction = transaction + self.editingRecurringTransaction = false } else if case .empty = recurringTransaction { self.selectedRecurringTransaction = nil + self.editingRecurringTransaction = false + } else if case .editing(_) = self.recurringTransaction { + self.editingRecurringTransaction = true } } } @@ -281,6 +316,31 @@ class DataStore : ObservableObject { } } + func newRecurringTransaction() { + guard case let .success(user) = self.currentUser else { + return + } + guard case let .success(budget) = self.budget else { + return + } + self.recurringTransaction = .editing(RecurringTransaction(createdBy: user.id, budgetId: budget.id)) + } + + func edit(_ transaction: RecurringTransaction) async { + self.recurringTransaction = .editing(transaction) + } + + func cancelEditRecurringTransaction() { + guard case let .editing(rt) = self.recurringTransaction else { + return + } + if !rt.id.isEmpty { + self.recurringTransaction = .success(rt) + } else { + self.recurringTransaction = .empty + } + } + func saveRecurringTransaction(_ transaction: RecurringTransaction) async { self.recurringTransaction = .loading do { @@ -329,20 +389,12 @@ class DataStore : ObservableObject { } } @Published var selectedTransaction: Transaction? = nil - private var budgetId: String = "" - private var categoryId: String? = nil func getTransactions() async { - guard case let .success(budget) = self.budget else { + guard let budgetId = self.budgetId else { self.transactions = .error(NetworkError.unknown) return } - self.budgetId = budget.id - if case let .success(category) = self.category { - self.categoryId = category.id - } else { - self.categoryId = nil - } self.transactions = .loading do { var categoryIds: [String] = [] diff --git a/Twigs/Recurring Transactions/RecurringTransactionDetailsView.swift b/Twigs/Recurring Transactions/RecurringTransactionDetailsView.swift index 68387bb..4bac9b6 100644 --- a/Twigs/Recurring Transactions/RecurringTransactionDetailsView.swift +++ b/Twigs/Recurring Transactions/RecurringTransactionDetailsView.swift @@ -28,12 +28,23 @@ struct RecurringTransactionDetailsView: View { Text(transaction.frequency.naturalDescription) Spacer().frame(height: 10) LabeledField(label: "start", value: transaction.start.toLocaleString(), loading: .constant(false), showDivider: true) - LabeledField(label: "end", value: transaction.end?.toLocaleString(), loading: .constant(false), showDivider: true) + LabeledField(label: "end", value: transaction.finish?.toLocaleString(), loading: .constant(false), showDivider: true) // CategoryLineItem() // BudgetLineItem() // UserLineItem() }.padding() } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + Task { + await dataStore.edit(transaction) + } + }) { + Text("edit") + } + } + } } } } diff --git a/Twigs/Recurring Transactions/RecurringTransactionForm.swift b/Twigs/Recurring Transactions/RecurringTransactionForm.swift new file mode 100644 index 0000000..f97432c --- /dev/null +++ b/Twigs/Recurring Transactions/RecurringTransactionForm.swift @@ -0,0 +1,156 @@ +// +// RecurringTransactionForm.swift +// Twigs +// +// Created by William Brawner on 5/20/22. +// Copyright © 2022 William Brawner. All rights reserved. +// + +import Foundation +import TwigsCore +import SwiftUI + +class RecurringTransactionForm: ObservableObject { + let apiService: TwigsApiService + let dataStore: DataStore + let transaction: TwigsCore.RecurringTransaction? + let createdBy: String + let transactionId: String + @Published var title: String + @Published var description: String + @Published var baseFrequencyUnit: String + @Published var frequencyUnit: FrequencyUnit + @Published var frequencyCount: String + @Published var daysOfWeek: Set + @Published var dayOfMonth: DayOfMonth + @Published var dayOfYear: DayOfYear + @Published var amount: String + @Published var start: Date + @Published var endCriteria: EndCriteria + @Published var end: Date? + @Published var type: TransactionType + @Published var budgetId: String { + didSet { + updateCategories() + } + } + @Published var categoryId: String + + @Published var categories: AsyncData<[TwigsCore.Category]> = .empty + private var cachedCategories: [TwigsCore.Category] = [] + let showDelete: Bool + + init( + dataStore: DataStore, + createdBy: String, + budgetId: String, + categoryId: String? = nil, + transaction: TwigsCore.RecurringTransaction? = nil + ) { + self.apiService = dataStore.apiService + self.budgetId = budgetId + self.categoryId = categoryId ?? "" + self.createdBy = createdBy + self.dataStore = dataStore + let baseTransaction = transaction ?? TwigsCore.RecurringTransaction(categoryId: categoryId, createdBy: createdBy, budgetId: budgetId) + self.transaction = transaction + self.transactionId = baseTransaction.id + self.title = baseTransaction.title + self.description = baseTransaction.description ?? "" + self.baseFrequencyUnit = baseTransaction.frequency.unit.baseName + self.frequencyUnit = baseTransaction.frequency.unit + if case let .weekly(daysOfWeek) = baseTransaction.frequency.unit { + self.daysOfWeek = daysOfWeek + } else { + self.daysOfWeek = Set() + } + if case let .monthly(dayOfMonth) = baseTransaction.frequency.unit { + self.dayOfMonth = dayOfMonth + } else { + self.dayOfMonth = DayOfMonth(day: 1)! + } + if case let .yearly(dayOfYear) = baseTransaction.frequency.unit { + self.dayOfYear = dayOfYear + } else { + self.dayOfYear = DayOfYear(month: 1, day: 1)! + } + self.frequencyCount = String(baseTransaction.frequency.count) + self.amount = baseTransaction.amountString + self.start = baseTransaction.start + self.end = baseTransaction.finish + if baseTransaction.finish != nil { + self.endCriteria = .onDate + } else { + self.endCriteria = .never + } + self.type = baseTransaction.type + self.showDelete = !baseTransaction.id.isEmpty + } + + func load() async { + self.categories = .loading + do { + let categories = try await apiService.getCategories(budgetId: self.budgetId, expense: nil, archived: false, count: nil, page: nil) + self.cachedCategories = categories + updateCategories() + } catch { + self.categories = .error(error) + } + } + + func save() async { + let amount = Double(self.amount) ?? 0.0 + var frequencyUnit: FrequencyUnit + switch self.frequencyUnit { + case .daily: + frequencyUnit = .daily + case .weekly(_): + frequencyUnit = .weekly(self.daysOfWeek) + case .monthly(_): + frequencyUnit = .monthly(self.dayOfMonth) + case .yearly(_): + frequencyUnit = .yearly(self.dayOfYear) + } + let components = Calendar.current.dateComponents([.hour, .minute, .second], from: self.start) + let time = Time(hours: components.hour!, minutes: components.minute!, seconds: components.second!)! + var end: Date? = nil + if case self.endCriteria = EndCriteria.onDate, let editedEnd = self.end, editedEnd > self.start { + end = editedEnd + } + await dataStore.saveRecurringTransaction(RecurringTransaction( + id: transactionId, + title: title, + description: description, + frequency: Frequency(unit: frequencyUnit, count: Int(frequencyCount) ?? 1, time: time)!, + start: start, + finish: end, + amount: Int(amount * 100.0), + categoryId: categoryId, + expense: type.toBool(), + createdBy: createdBy, + budgetId: budgetId + )) + } + + func delete() async { + guard let transaction = self.transaction else { + return + } + await dataStore.deleteRecurringTransaction(transaction) + } + + private func updateCategories() { + self.categories = .success(cachedCategories.filter { + $0.expense == self.type.toBool() + }) + } +} + +enum EndCriteria: String, Identifiable, CaseIterable { + var id: String { + return self.rawValue + } + + case never = "never" + case onDate = "onDate" +} diff --git a/Twigs/Recurring Transactions/RecurringTransactionFormView.swift b/Twigs/Recurring Transactions/RecurringTransactionFormView.swift new file mode 100644 index 0000000..73b3fc2 --- /dev/null +++ b/Twigs/Recurring Transactions/RecurringTransactionFormView.swift @@ -0,0 +1,144 @@ +// +// RecurringTransactionFormView.swift +// Twigs +// +// Created by William Brawner on 5/18/22. +// Copyright © 2022 William Brawner. All rights reserved. +// + +import SwiftUI +import TwigsCore + +struct RecurringTransactionFormView: View { + @EnvironmentObject var dataStore: DataStore + @ObservedObject var transactionForm: RecurringTransactionForm + @State private var showingAlert = false + + var body: some View { + NavigationView { + switch self.dataStore.recurringTransaction { + case .loading: + EmbeddedLoadingView() + default: + Form { + Section { + TextField(LocalizedStringKey("prompt_name"), text: $transactionForm.title) + .textInputAutocapitalization(.words) + TextField(LocalizedStringKey("prompt_description"), text: $transactionForm.description) + .textInputAutocapitalization(.sentences) + TextField(LocalizedStringKey("prompt_amount"), text: $transactionForm.amount) + .keyboardType(.decimalPad) + Picker(LocalizedStringKey("prompt_type"), selection: $transactionForm.type) { + ForEach(TransactionType.allCases) { type in + Text(type.localizedKey) + } + } + } + Section { + HStack { + Text("Repeat every") + TextField("count", text: $transactionForm.frequencyCount) + .keyboardType(.decimalPad) + } + Picker(selection: self.$transactionForm.baseFrequencyUnit.animation(), content: { + ForEach(FrequencyUnit.allCases) { + Text(LocalizedStringKey($0.baseName)).tag($0.baseName) + } + }, label: { + Text("frequency") + }) + .pickerStyle(.segmented) + FrequencyPickerView( + frequencyUnit: $transactionForm.baseFrequencyUnit, + daysOfWeek: $transactionForm.daysOfWeek, + dayOfMonth: $transactionForm.dayOfMonth, + dayOfYear: $transactionForm.dayOfYear + ) + } + Section(footer: Text("note_end_optional")) { + DatePicker(selection: $transactionForm.start, label: { Text(LocalizedStringKey("prompt_start")) }) + Picker(LocalizedStringKey("prompt_end"), selection: $transactionForm.endCriteria.animation()) { + ForEach(EndCriteria.allCases) { criteria in + Text(LocalizedStringKey(criteria.rawValue)).tag(criteria) + } + } + if case .onDate = transactionForm.endCriteria { + DatePicker( + "", + selection: Binding(get: {transactionForm.end ?? Date()}, set: {transactionForm.end = $0}) + ) + } + } + Section { + CategoryPicker(categories: $transactionForm.categories, categoryId: $transactionForm.categoryId) + } + if transactionForm.showDelete { + Button(action: { + self.showingAlert = true + }) { + Text(LocalizedStringKey("delete")) + .foregroundColor(.red) + } + .alert(isPresented:$showingAlert) { + Alert( + title: Text(LocalizedStringKey("confirm_delete")), + message: Text(LocalizedStringKey("cannot_undo")), + primaryButton: .destructive( + Text(LocalizedStringKey("delete")), + action: { Task { await transactionForm.delete() }} + ), + secondaryButton: .cancel() + ) + } + } else { + EmptyView() + } + }.environmentObject(transactionForm) + .task { + await transactionForm.load() + } + .navigationTitle(transactionForm.transactionId.isEmpty ? "add_recurring_transaction" : "edit_recurring_transaction") + .navigationBarItems( + leading: Button("cancel", action: { dataStore.cancelEditRecurringTransaction() }), + trailing: Button("save", action: { + Task { + await transactionForm.save() + } + }) + ) + } + } + } +} + +struct FrequencyPickerView: View { + @Binding var frequencyUnit: String + @Binding var daysOfWeek: Set + @Binding var dayOfMonth: DayOfMonth + @Binding var dayOfYear: DayOfYear + + @ViewBuilder + var body: some View { + switch frequencyUnit { + case "week": + WeeklyFrequencyPicker(selection: $daysOfWeek) + case "month": + MonthlyFrequencyPicker(dayOfMonth: $dayOfMonth) + case "year": + YearlyFrequencyPicker(dayOfYear: $dayOfYear) + default: + EmptyView() + } + } +} + +struct RecurringTransactionFormView_Previews: PreviewProvider { + static var dataStore = DataStore(TwigsInMemoryCacheService()) + static var previews: some View { + RecurringTransactionFormView(transactionForm: RecurringTransactionForm( + dataStore: dataStore, + createdBy: MockUserRepository.currentUser.id, + budgetId: MockBudgetRepository.budget.id + )).environmentObject(dataStore) + } +} diff --git a/Twigs/Recurring Transactions/RecurringTransactionsListView.swift b/Twigs/Recurring Transactions/RecurringTransactionsListView.swift index c735cc9..c9e27d5 100644 --- a/Twigs/Recurring Transactions/RecurringTransactionsListView.swift +++ b/Twigs/Recurring Transactions/RecurringTransactionsListView.swift @@ -24,6 +24,29 @@ struct RecurringTransactionsListView: View { } } } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + dataStore.newRecurringTransaction() + }, label: { + Image(systemName: "plus").padding() + }) + } + } + .sheet( + isPresented: $dataStore.editingRecurringTransaction, + onDismiss: { + dataStore.cancelEditRecurringTransaction() + }, + content: { + RecurringTransactionFormView(transactionForm: RecurringTransactionForm( + dataStore: dataStore, + createdBy: dataStore.currentUserId ?? "", + budgetId: dataStore.budgetId ?? "", + categoryId: dataStore.selectedRecurringTransaction?.categoryId ?? dataStore.categoryId, + transaction: dataStore.selectedRecurringTransaction + )) + }) } } diff --git a/Twigs/Recurring Transactions/RecurringTransactionsRepository.swift b/Twigs/Recurring Transactions/RecurringTransactionsRepository.swift index 1693831..cfd9271 100644 --- a/Twigs/Recurring Transactions/RecurringTransactionsRepository.swift +++ b/Twigs/Recurring Transactions/RecurringTransactionsRepository.swift @@ -18,7 +18,7 @@ class MockRecurringTransactionRepository: RecurringTransactionsRepository { description: "A mock transaction used for testing", frequency: Frequency(unit: .daily, count: 1, time: Time(from: "09:00:00")!)!, start: Date(), - end: nil, + finish: nil, amount: 10000, categoryId: MockCategoryRepository.category.id, expense: true, diff --git a/Twigs/Transaction/TransactionFormSheet.swift b/Twigs/Transaction/TransactionFormSheet.swift index f25b36a..0eea445 100644 --- a/Twigs/Transaction/TransactionFormSheet.swift +++ b/Twigs/Transaction/TransactionFormSheet.swift @@ -34,8 +34,8 @@ struct TransactionFormSheet: View { Text(type.localizedKey) } } - BudgetPicker() - CategoryPicker() + BudgetPicker(budgets: $transactionForm.budgets, budgetId: $transactionForm.budgetId) + CategoryPicker(categories: $transactionForm.categories, categoryId: $transactionForm.categoryId) if transactionForm.showDelete { Button(action: { self.showingAlert = true @@ -76,18 +76,19 @@ struct TransactionFormSheet: View { } struct BudgetPicker: View { - @EnvironmentObject var transactionForm: TransactionForm + @Binding var budgets: AsyncData<[Budget]> + @Binding var budgetId: String @ViewBuilder var body: some View { - if case let .success(budgets) = self.transactionForm.budgets { - Picker(LocalizedStringKey("prompt_budget"), selection: $transactionForm.budgetId) { + if case let .success(budgets) = budgets { + Picker(LocalizedStringKey("prompt_budget"), selection: $budgetId) { ForEach(budgets) { budget in Text(budget.name) } } } else { - Picker(LocalizedStringKey("prompt_budget"), selection: $transactionForm.budgetId) { + Picker(LocalizedStringKey("prompt_budget"), selection: $budgetId) { Text("") } } @@ -95,14 +96,15 @@ struct BudgetPicker: View { } struct CategoryPicker: View { - @EnvironmentObject var transactionForm: TransactionForm - + @Binding var categories: AsyncData<[TwigsCore.Category]> + @Binding var categoryId: String + @ViewBuilder var body: some View { - if case let .success(categories) = self.transactionForm.categories { - Picker(LocalizedStringKey("prompt_category"), selection: $transactionForm.categoryId) { + if case let .success(categories) = categories { + Picker(LocalizedStringKey("prompt_category"), selection: $categoryId) { ForEach(categories) { category in - Text(category.title) + Text(category.title).tag(category.id) } } } else { diff --git a/Twigs/Views/MonthlyFrequencyPicker.swift b/Twigs/Views/MonthlyFrequencyPicker.swift new file mode 100644 index 0000000..e1f03aa --- /dev/null +++ b/Twigs/Views/MonthlyFrequencyPicker.swift @@ -0,0 +1,124 @@ +// +// MonthlyFrequencyPicker.swift +// Twigs +// +// Created by William Brawner on 5/27/22. +// Copyright © 2022 William Brawner. All rights reserved. +// + +import SwiftUI +import TwigsCore + +struct MonthlyFrequencyPicker: UIViewRepresentable { + @Binding var dayOfMonth: DayOfMonth + @State var dayOfWeek: Int + @State var intDay: Int + @State var ordinalDay: Int + + init(dayOfMonth: Binding) { + self._dayOfMonth = dayOfMonth + if case let .fixed(intDay) = dayOfMonth.wrappedValue { + self.intDay = intDay - 1 + self.ordinalDay = 0 + self.dayOfWeek = 0 + } else if case let .ordinal(ordinalDay, dayOfWeek) = dayOfMonth.wrappedValue { + self.intDay = 0 + self.ordinalDay = Ordinal.allCases.firstIndex(of: ordinalDay)! + self.dayOfWeek = DayOfWeek.allCases.firstIndex(of: dayOfWeek)! + } else { + self.intDay = 0 + self.dayOfWeek = 0 + self.ordinalDay = 0 + } + } + + func makeCoordinator() -> MonthlyFrequencyPicker.Coordinator { + Coordinator(self, selectedOrdinal: $ordinalDay, selectedDay: $intDay, selectedDayOfWeek: $dayOfWeek) + } + + func makeUIView(context: UIViewRepresentableContext) -> UIPickerView { + let picker = UIPickerView(frame: .zero) + picker.dataSource = context.coordinator + picker.delegate = context.coordinator + return picker + } + + func updateUIView(_ view: UIPickerView, context: UIViewRepresentableContext) { + view.selectRow(ordinalDay, inComponent: 0, animated: false) + let component2Selection = ordinalDay == 0 ? intDay : dayOfWeek + view.selectRow(component2Selection, inComponent: 1, animated: true) + view.reloadComponent(1) + } + + class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate { + let ordinals = Ordinal.allCases.map { + $0.rawValue.lowercased() + } + + var parent: MonthlyFrequencyPicker + @Binding var selectedOrdinal: Int { + didSet { + // This is a workaround for the pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) function not getting the correct values for the selectedOrdinal + ordinal = self.selectedOrdinal + } + } + @Binding var selectedDay: Int + @Binding var selectedDayOfWeek: Int + + private var ordinal = 0 + + init(_ pickerView: MonthlyFrequencyPicker, selectedOrdinal: Binding, selectedDay: Binding, selectedDayOfWeek: Binding) { + self.parent = pickerView + self._selectedOrdinal = selectedOrdinal + self._selectedDay = selectedDay + self._selectedDayOfWeek = selectedDayOfWeek + } + + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 2 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + if component == 0 { + return ordinals.count + } + if ordinal == 0 { + return 31 + } else { + return 7 + } + } + + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + if component == 0 { + return NSLocalizedString(ordinals[row], comment: "") + } + + if ordinal == 0 { + return String(row + 1) + } else { + return NSLocalizedString(DayOfWeek.allCases[row].rawValue, comment: "") + } + } + + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + if component == 0 { + selectedOrdinal = row + return + } + if ordinal == 0 { + selectedDay = row + } else { + selectedDayOfWeek = row + } + } + } +} + +struct MonthlyFrequencyPicker_Previews: PreviewProvider { + @State static var dayOfMonth: DayOfMonth = .fixed(1) + + static var previews: some View { + MonthlyFrequencyPicker(dayOfMonth: $dayOfMonth) + } +} diff --git a/Twigs/Views/MultiPicker.swift b/Twigs/Views/MultiPicker.swift new file mode 100644 index 0000000..fcdfe04 --- /dev/null +++ b/Twigs/Views/MultiPicker.swift @@ -0,0 +1,65 @@ +// +// MultiPicker.swift +// Twigs +// +// Created by William Brawner on 5/20/22. +// Copyright © 2022 William Brawner. All rights reserved. +// Adapted from https://stackoverflow.com/a/58664469 + +import SwiftUI + +struct MultiPicker: UIViewRepresentable { + var data: [[String]] + @Binding var selections: [Int] + + func makeCoordinator() -> MultiPicker.Coordinator { + Coordinator(self) + } + + func makeUIView(context: UIViewRepresentableContext) -> UIPickerView { + let picker = UIPickerView(frame: .zero) + + picker.dataSource = context.coordinator + picker.delegate = context.coordinator + + return picker + } + + func updateUIView(_ view: UIPickerView, context: UIViewRepresentableContext) { + for i in 0...(self.selections.count - 1) { + view.selectRow(self.selections[i], inComponent: i, animated: false) + } + } + + class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate { + var parent: MultiPicker + + init(_ pickerView: MultiPicker) { + self.parent = pickerView + } + + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return self.parent.data.count + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + return self.parent.data[component].count + } + + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + return self.parent.data[component][row] + } + + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + self.parent.selections[component] = row + } + } +} + +struct MultiPicker_Previews: PreviewProvider { + @State static var selections: [Int] = [0, 0] + + static var previews: some View { + MultiPicker(data: [["a", "b", "c"], ["one", "two", "three"]], selections: $selections) + } +} diff --git a/Twigs/Views/WeeklyFrequencyPicker.swift b/Twigs/Views/WeeklyFrequencyPicker.swift new file mode 100644 index 0000000..b6612f5 --- /dev/null +++ b/Twigs/Views/WeeklyFrequencyPicker.swift @@ -0,0 +1,62 @@ +// +// WeeklyFrequencyPicker.swift +// Twigs +// +// Created by William Brawner on 5/27/22. +// Copyright © 2022 William Brawner. All rights reserved. +// + +import SwiftUI +import TwigsCore + +struct WeeklyFrequencyPicker: View { + @Binding var selection: Set + + var body: some View { + VStack { + HStack { + ForEach(DayOfWeek.allCases.slice(count: 4, page: 1)) { dayOfWeek in + Toggle(isOn: .constant(selection.contains(dayOfWeek))) { + Text(LocalizedStringKey(dayOfWeek.rawValue.lowercased())) + .lineLimit(1) + .onTapGesture { + if selection.contains(dayOfWeek) { + selection.remove(dayOfWeek) + } else { + selection.update(with: dayOfWeek) + } + } + } + .toggleStyle(.button) + .onSubmit { + print("Toggle selected for \(dayOfWeek)") + } + } + } + HStack { + ForEach(DayOfWeek.allCases.slice(count: 4, page: 2)) { dayOfWeek in + Toggle(isOn: .constant(selection.contains(dayOfWeek))) { + Text(LocalizedStringKey(dayOfWeek.rawValue.lowercased())) + .lineLimit(1) + .onTapGesture { + if selection.contains(dayOfWeek) { + selection.remove(dayOfWeek) + } else { + selection.update(with: dayOfWeek) + } + } + } + .toggleStyle(.button) + } + } + } + } +} + +struct WeeklyFrequencyPicker_Previews: PreviewProvider { + @State static var selection: Set = Set() + + static var previews: some View { + WeeklyFrequencyPicker(selection: $selection) + } +} diff --git a/Twigs/Views/YearlyFrequencyPicker.swift b/Twigs/Views/YearlyFrequencyPicker.swift new file mode 100644 index 0000000..a47dc4c --- /dev/null +++ b/Twigs/Views/YearlyFrequencyPicker.swift @@ -0,0 +1,118 @@ +// +// YearlyFrequencyPicker.swift +// Twigs +// +// Created by William Brawner on 5/27/22. +// Copyright © 2022 William Brawner. All rights reserved. +// + +import SwiftUI +import TwigsCore + +struct YearlyFrequencyPicker: UIViewRepresentable { + @Binding var dayOfYear: DayOfYear + @State var selectedMonth: Int { + didSet { + selectedDay = min(self.selectedDay, DayOfYear.maxDays(inMonth: self.selectedMonth + 1) - 1) + } + } + @State var selectedDay: Int { + didSet { + if let dayOfYear = DayOfYear(month: selectedMonth + 1, day: selectedDay + 1) { + self.dayOfYear = dayOfYear + } + } + } + + init(dayOfYear: Binding) { + self._dayOfYear = dayOfYear + self.selectedMonth = dayOfYear.wrappedValue.month - 1 + self.selectedDay = dayOfYear.wrappedValue.day - 1 + } + + func makeCoordinator() -> YearlyFrequencyPicker.Coordinator { + Coordinator(self, selectedMonth: $selectedMonth, selectedDay: $selectedDay) + } + + func makeUIView(context: UIViewRepresentableContext) -> UIPickerView { + let picker = UIPickerView(frame: .zero) + picker.dataSource = context.coordinator + picker.delegate = context.coordinator + return picker + } + + func updateUIView(_ view: UIPickerView, context: UIViewRepresentableContext) { + view.selectRow(selectedMonth, inComponent: 0, animated: false) + view.selectRow(selectedDay, inComponent: 1, animated: true) + view.reloadComponent(1) + } + + class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate { + let months = [ + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december" + ] + + var parent: YearlyFrequencyPicker + @Binding var selectedMonth: Int { + didSet { + // This is a workaround for the pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) function not getting the correct values for selectedMonth + month = self.selectedMonth + } + } + @Binding var selectedDay: Int + + private var month = 0 + + init(_ pickerView: YearlyFrequencyPicker, selectedMonth: Binding, selectedDay: Binding ) { + self.parent = pickerView + self._selectedMonth = selectedMonth + self._selectedDay = selectedDay + } + + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 2 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + if component == 0 { + return months.count + } + return DayOfYear.maxDays(inMonth: month + 1) + } + + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + if component == 0 { + return NSLocalizedString(months[row], comment: "") + } + + return String(row + 1) + } + + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + if component == 0 { + selectedMonth = row + } else { + selectedDay = row + } + } + } +} + +struct YearlyFrequencyPicker_Previews: PreviewProvider { + @State static var dayOfYear: DayOfYear = (DayOfYear(month: 1, day: 1)!) + + static var previews: some View { + YearlyFrequencyPicker(dayOfYear: $dayOfYear) + } +} diff --git a/Twigs/en.lproj/Localizable.strings b/Twigs/en.lproj/Localizable.strings index ceb8725..49f3a08 100644 --- a/Twigs/en.lproj/Localizable.strings +++ b/Twigs/en.lproj/Localizable.strings @@ -80,3 +80,54 @@ // MARK: Recurring Transactions "recurring" = "Recurring"; "recurring_transactions" = "Recurring Transactions"; +"add_recurring_transaction" = "Add Recurring Transaction"; +"edit_recurring_transaction" = "Edit Recurring Transaction"; +"prompt_start" = "Start"; +"prompt_end" = "End"; +"frequency" = "Frequency"; +"note_end_optional" = "Note: The end date is optional"; + +"day" = "Day"; +"week" = "Week"; +"month" = "Month"; +"year" = "Year"; + +"sunday" = "SUN"; +"monday" = "MON"; +"tuesday" = "TUES"; +"wednesday" = "WED"; +"thursday" = "THURS"; +"friday" = "FRI"; +"saturday" = "SAT"; + +"first" = "First"; +"second" = "Second"; +"third" = "Third"; +"fourth" = "Fourth"; +"last" = "Last"; + +"SUNDAY" = "Sunday"; +"MONDAY" = "Monday"; +"TUESDAY" = "Tuesday"; +"WEDNESDAY" = "Wednesday"; +"THURSDAY" = "Thursday"; +"FRIDAY" = "Friday"; +"SATURDAY" = "Saturday"; + +"january" = "January"; +"february" = "February"; +"march" = "March"; +"april" = "April"; +"may" = "May"; +"june" = "June"; +"july" = "July"; +"august" = "August"; +"september" = "September"; +"october" = "October"; +"november" = "November"; +"december" = "December"; + +"start" = "Start"; +"end" = "End"; +"never" = "Never"; +"onDate" = "On Date"; diff --git a/Twigs/es.lproj/Localizable.strings b/Twigs/es.lproj/Localizable.strings index f92be66..32dd067 100644 --- a/Twigs/es.lproj/Localizable.strings +++ b/Twigs/es.lproj/Localizable.strings @@ -81,3 +81,54 @@ // MARK: Recurring Transactions "recurring" = "Recurrentes"; "recurring_transactions" = "Transacciones Recurrentes"; +"add_recurring_transaction" = "Agregar Transacción Recurrente"; +"edit_recurring_transaction" = "Editar Transacción Recurrente"; +"prompt_start" = "Inicio"; +"prompt_end" = "Final"; +"note_end_optional" = "Nota: La fecha final es opcional"; +"frequency" = "Frequencia"; + +"day" = "Día"; +"week" = "Semana"; +"month" = "Mes"; +"year" = "Año"; + +"sunday" = "DOM"; +"monday" = "LUN"; +"tuesday" = "MAR"; +"wednesday" = "MIE"; +"thursday" = "JUE"; +"friday" = "VIE"; +"saturday" = "SAB"; + +"first" = "Primer"; +"second" = "Segundo"; +"third" = "Tercer"; +"fourth" = "Cuarto"; +"last" = "Último"; + +"SUNDAY" = "domingo"; +"MONDAY" = "lunes"; +"TUESDAY" = "martes"; +"WEDNESDAY" = "miércoles"; +"THURSDAY" = "jueves"; +"FRIDAY" = "viernes"; +"SATURDAY" = "sábado"; + +"january" = "enero"; +"february" = "febrero"; +"march" = "marzo"; +"april" = "abril"; +"may" = "mayo"; +"june" = "junio"; +"july" = "julio"; +"august" = "agosto"; +"september" = "septiembre"; +"october" = "octubre"; +"november" = "noviembre"; +"december" = "diciembre"; + +"start" = "Inicio"; +"end" = "Fin"; +"never" = "Nunca"; +"onDate" = "Fecha";