diff --git a/Twigs.xcodeproj/project.pbxproj b/Twigs.xcodeproj/project.pbxproj index 7b467d3..d900e91 100644 --- a/Twigs.xcodeproj/project.pbxproj +++ b/Twigs.xcodeproj/project.pbxproj @@ -45,11 +45,16 @@ 28FE6B04234449DC00D5543E /* TransactionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6B03234449DC00D5543E /* TransactionListView.swift */; }; 28FE6B0623444A9800D5543E /* TransactionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6B0523444A9800D5543E /* TransactionDetailsView.swift */; }; 543ECE42233E82A40018A9D9 /* AuthenticationDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */; }; + 801D08CC275ECEFA00931465 /* RecurringTransactionsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */; }; + 801D08CE275F189E00931465 /* RecurringTransactionsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */; }; + 801D08D0275F1AE300931465 /* RecurringTransactionDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08CF275F1AE300931465 /* RecurringTransactionDataStore.swift */; }; + 801D08D2275FB7DE00931465 /* RecurringTransactionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08D1275FB7DE00931465 /* RecurringTransactionDetailsView.swift */; }; 8043EB84271F26ED00498E73 /* CategoryDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */; }; 806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806C784F272B700B00FA1375 /* TwigsApp.swift */; }; 80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80820144275FFD380040996E /* SidebarBudgetView.swift */; }; 8094A9C327567CAC006C6C62 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 8094A9C227567CAC006C6C62 /* Collections */; }; 809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */; }; + 80D6B1F1275B11DE0075D0EC /* RecurringTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D6B1F0275B11DE0075D0EC /* RecurringTransaction.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -115,11 +120,16 @@ 28FE6B03234449DC00D5543E /* TransactionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionListView.swift; sourceTree = ""; }; 28FE6B0523444A9800D5543E /* TransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDetailsView.swift; sourceTree = ""; }; 543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationDataStore.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 = ""; }; + 801D08CF275F1AE300931465 /* RecurringTransactionDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionDataStore.swift; sourceTree = ""; }; + 801D08D1275FB7DE00931465 /* RecurringTransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionDetailsView.swift; sourceTree = ""; }; 8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailsView.swift; sourceTree = ""; }; 806C784F272B700B00FA1375 /* TwigsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsApp.swift; sourceTree = ""; }; 80820144275FFD380040996E /* SidebarBudgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarBudgetView.swift; sourceTree = ""; }; 809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryFormSheet.swift; sourceTree = ""; }; 809B94242722597800B1DAE2 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + 80D6B1F0275B11DE0075D0EC /* RecurringTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransaction.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -231,6 +241,7 @@ 28AC9527233C430A00BFB70A /* Network */, 28AC94F5233C373A00BFB70A /* Preview Content */, 2821269F2359299D00072D52 /* Profile */, + 80D6B1EF275B11C10075D0EC /* Recurring Transactions */, 28AC9526233C42F800BFB70A /* Transaction */, 28AC952A233C433C00BFB70A /* User */, 2857EAEB233DA2F90026BC83 /* Views */, @@ -299,6 +310,18 @@ path = User; sourceTree = ""; }; + 80D6B1EF275B11C10075D0EC /* Recurring Transactions */ = { + isa = PBXGroup; + children = ( + 80D6B1F0275B11DE0075D0EC /* RecurringTransaction.swift */, + 801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */, + 801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */, + 801D08CF275F1AE300931465 /* RecurringTransactionDataStore.swift */, + 801D08D1275FB7DE00931465 /* RecurringTransactionDetailsView.swift */, + ); + path = "Recurring Transactions"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -444,13 +467,16 @@ 282126622357E45F00072D52 /* TransactionEditView.swift in Sources */, 28FE6AFC23441E4500D5543E /* CategoryRepository.swift in Sources */, 2841022C2342D8E400EAFA29 /* Budget.swift in Sources */, + 801D08CE275F189E00931465 /* RecurringTransactionsRepository.swift in Sources */, 2841022723419A2B00EAFA29 /* TabbedBudgetView.swift in Sources */, + 801D08D2275FB7DE00931465 /* RecurringTransactionDetailsView.swift in Sources */, 28FE6B0623444A9800D5543E /* TransactionDetailsView.swift in Sources */, 28FE6AF42342E3CB00D5543E /* BudgetsDataStore.swift in Sources */, 28AC952C233C434800BFB70A /* UserRepository.swift in Sources */, 28FE6B04234449DC00D5543E /* TransactionListView.swift in Sources */, 28AC94F2233C373900BFB70A /* LoginView.swift in Sources */, 282126BB235CDD3C00072D52 /* BudgetDetailsView.swift in Sources */, + 801D08D0275F1AE300931465 /* RecurringTransactionDataStore.swift in Sources */, 28AC9525233C42D100BFB70A /* TwigsApiService.swift in Sources */, 2888234723512DBF003D3847 /* Observable.swift in Sources */, 2857EAED233DA30B0026BC83 /* LoadingView.swift in Sources */, @@ -466,6 +492,7 @@ 28AC9529233C433400BFB70A /* TransactionRepository.swift in Sources */, 809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */, 28FE6AF62342E4CC00D5543E /* BudgetRepository.swift in Sources */, + 80D6B1F1275B11DE0075D0EC /* RecurringTransaction.swift in Sources */, 28FE6B022344331B00D5543E /* TransactionDataStore.swift in Sources */, 543ECE42233E82A40018A9D9 /* AuthenticationDataStore.swift in Sources */, 80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */, @@ -474,6 +501,7 @@ 806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */, 28AC952E233C43A300BFB70A /* User.swift in Sources */, 28CE8B9523525F990072BC4C /* Extensions.swift in Sources */, + 801D08CC275ECEFA00931465 /* RecurringTransactionsListView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -581,7 +609,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.7; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -638,7 +666,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.7; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = ""; diff --git a/Twigs/Budget/BudgetDetailsView.swift b/Twigs/Budget/BudgetDetailsView.swift index b8b78d5..c5ec496 100644 --- a/Twigs/Budget/BudgetDetailsView.swift +++ b/Twigs/Budget/BudgetDetailsView.swift @@ -62,11 +62,6 @@ struct BudgetDetailsView: View { } } }.listStyle(.insetGrouped) - .navigationBarItems(leading: HStack { - Button("budgets", action: { - self.budgetDataStore.showBudgetSelection = true - }).padding() - }) default: Text("An error has ocurred") } diff --git a/Twigs/Budget/BudgetListsView.swift b/Twigs/Budget/BudgetListsView.swift index 0c6822d..45ff46f 100644 --- a/Twigs/Budget/BudgetListsView.swift +++ b/Twigs/Budget/BudgetListsView.swift @@ -37,7 +37,7 @@ struct BudgetListsView: View { struct BudgetListItemView: View { @EnvironmentObject var budgetDataStore: BudgetsDataStore let budget: Budget - + var body: some View { Button( action: { @@ -58,7 +58,7 @@ struct BudgetListItemView: View { } ) } - + init (_ budget: Budget) { self.budget = budget } diff --git a/Twigs/LoginView.swift b/Twigs/LoginView.swift index 9bb6e75..865c923 100644 --- a/Twigs/LoginView.swift +++ b/Twigs/LoginView.swift @@ -34,12 +34,15 @@ struct LoginView: View { Text("info_login") TextField(LocalizedStringKey("prompt_server"), text: self.$server) .textFieldStyle(RoundedBorderTextFieldStyle()) + .textContentType(.URL) TextField(LocalizedStringKey("prompt_username"), text: self.$username) .autocapitalization(UITextAutocapitalizationType.none) .textFieldStyle(RoundedBorderTextFieldStyle()) + .textContentType(.username) SecureField(LocalizedStringKey("prompt_password"), text: self.$password, prompt: nil) .textFieldStyle(RoundedBorderTextFieldStyle()) .textContentType(UITextContentType.password) + .textContentType(.password) Button("action_login", action: { self.userData.login(server: self.server, username: self.username, password: self.password) }).buttonStyle(DefaultButtonStyle()) diff --git a/Twigs/Network/TwigsApiService.swift b/Twigs/Network/TwigsApiService.swift index 3db2719..88b3941 100644 --- a/Twigs/Network/TwigsApiService.swift +++ b/Twigs/Network/TwigsApiService.swift @@ -9,7 +9,7 @@ import Foundation import Combine -class TwigsApiService { +class TwigsApiService: RecurringTransactionsRepository { let requestHelper: RequestHelper init(_ requestHelper: RequestHelper) { @@ -207,6 +207,27 @@ class TwigsApiService { func deleteUser(_ user: User) -> AnyPublisher { return requestHelper.delete("/api/users/\(user.id)") } + + // MARK: Recurring Transactions + func getRecurringTransactions(budgetId: String) -> AnyPublisher<[RecurringTransaction], NetworkError> { + return requestHelper.get("/api/recurringtransactions", queries: ["budgetId": [budgetId]]) + } + + func getRecurringTransaction(_ id: String) -> AnyPublisher { + return requestHelper.get("/api/recurringtransactions/\(id)") + } + + func createRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher { + return requestHelper.post("/api/recurringtransactions", data: transaction, type: RecurringTransaction.self) + } + + func updateRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher { + return requestHelper.put("/api/recurringtransactions/\(transaction.id)", data: transaction) + } + + func deleteRecurringTransaction(_ id: String) -> AnyPublisher { + return requestHelper.delete("/api/recurringtransactions/\(id)") + } } private let BASE_URL = "BASE_URL" diff --git a/Twigs/Recurring Transactions/RecurringTransaction.swift b/Twigs/Recurring Transactions/RecurringTransaction.swift new file mode 100644 index 0000000..f3dfdf3 --- /dev/null +++ b/Twigs/Recurring Transactions/RecurringTransaction.swift @@ -0,0 +1,259 @@ +// +// RecurringTransaction.swift +// Twigs +// +// Created by William Brawner on 12/3/21. +// Copyright © 2021 William Brawner. All rights reserved. +// + +import Foundation + +struct RecurringTransaction: Identifiable, Hashable, Codable { + let id: String + let title: String + let description: String? + let frequency: Frequency + let start: Date + let end: Date? + let amount: Int + let categoryId: String? + let expense: Bool + let createdBy: String + let budgetId: String +} + +struct Frequency: Hashable, Codable, CustomStringConvertible { + let unit: FrequencyUnit + let count: Int + let time: Time + + init?(unit: FrequencyUnit, count: Int, time: Time) { + if count < 1 { + return nil + } + self.unit = unit + self.count = count + self.time = time + } + + init?(from string: String) { + let parts = string.split(separator: ";") + guard let count = Int(parts[1]) else { + return nil + } + var timeIndex = 3 + switch parts[0] { + case "D": + self.unit = .daily + timeIndex = 2 + case "W": + let daysOfWeek = parts[2].split(separator: ",").compactMap { dayOfWeek in + DayOfWeek(rawValue: String(dayOfWeek)) + } + if daysOfWeek.isEmpty { + return nil + } + self.unit = .weekly(Set(daysOfWeek)) + case "M": + guard let dayOfMonth = DayOfMonth(from: String(parts[2])) else { + return nil + } + self.unit = .monthly(dayOfMonth) + case "Y": + guard let dayOfYear = DayOfYear(from: String(parts[2])) else { + return nil + } + self.unit = .yearly(dayOfYear) + default: + return nil + } + guard let time = Time(from: String(parts[timeIndex])) else { + return nil + } + self.time = time + self.count = count + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let frequencyString = try container.decode(String.self) + self.init(from: frequencyString)! + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(description) + } + + var description: String { + // TODO: Make the backend representation of this more sensible and then use this + // return [unit.description, count.description, time.description].joined(separator: ";") + let unitParts = "\(unit)".split(separator: ";") + if unitParts.count == 1 { + return [unitParts[0].description, count.description, time.description].joined(separator: ";") + } else{ + return [unitParts[0].description, count.description, unitParts[1].description, time.description].joined(separator: ";") + } + } + +} + +enum FrequencyUnit: Hashable, CustomStringConvertible { + case daily + case weekly(Set) + case monthly(DayOfMonth) + case yearly(DayOfYear) + + var description: String { + switch self { + case .daily: + return "D" + case .weekly(let daysOfWeek): + return String(format: "W;%s", daysOfWeek.map { $0.rawValue }.joined(separator: ",")) + case .monthly(let dayOfMonth): + return String(format: "M;%s", dayOfMonth.description) + case .yearly(let dayOfYear): + return String(format: "Y;%s", dayOfYear.description) + } + } +} + +struct Time: Hashable, CustomStringConvertible { + let hours: Int + let minutes: Int + let seconds: Int + + init?(hours: Int, minutes: Int, seconds: Int) { + if hours < 0 || hours > 23 { + return nil + } + if minutes < 0 || minutes > 59 { + return nil + } + if seconds < 0 || seconds > 59 { + return nil + } + self.hours = hours + self.minutes = minutes + self.seconds = seconds + } + + init?(from string: String) { + let parts = string.split(separator: ":").compactMap { + Int($0) + } + if parts.count != 3 { + return nil + } + self.init(hours: parts[0], minutes: parts[1], seconds: parts[2]) + } + + var description: String { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } +} + +enum DayOfMonth: Hashable, CustomStringConvertible { + case positional(Position, DayOfWeek) + case fixed(Int) + init?(position: Position, dayOfWeek: DayOfWeek) { + if position == .day { + return nil + } + self = .positional(position, dayOfWeek) + } + + init?(day: Int) { + if day < 1 || day > 31 { + return nil + } + self = .fixed(day) + } + + init?(from string: String) { + let parts = string.split(separator: "-") + guard let position = Position.init(rawValue: String(parts[0])) else { + return nil + } + if position == .day { + guard let day = Int(parts[1]) else { + return nil + } + self = .fixed(day) + } else { + guard let dayOfWeek = DayOfWeek(rawValue: String(parts[1])) else { + return nil + } + self = .positional(position, dayOfWeek) + } + } + + var description: String { + switch self { + case .positional(let position, let dayOfWeek): + return "\(position)-\(dayOfWeek)" + case .fixed(let day): + return "\(Position.day)-\(day)" + } + } +} + +enum Position: String, Hashable { + case day = "DAY" + case first = "FIRST" + case second = "SECOND" + case third = "THIRD" + case fourth = "FOURTH" + case last = "LAST" +} + +enum DayOfWeek: String, Hashable { + case monday = "MONDAY" + case tuesday = "TUESDAY" + case wednesday = "WEDNESDAY" + case thursday = "THURSDAY" + case friday = "FRIDAY" + case saturday = "SATURDAY" + case sunday = "SUNDAY" +} + +struct DayOfYear: Hashable, CustomStringConvertible { + let month: Int + let day: Int + + init?(month: Int, day: Int) { + var maxDay: Int + switch month { + case 2: + maxDay = 29; + break; + case 4, 6, 9, 11: + maxDay = 30; + break; + default: + maxDay = 31; + } + if day < 1 || day > maxDay { + return nil + } + if month < 1 || month > 12 { + return nil + } + self.day = day + self.month = month + } + + init?(from string: String) { + let parts = string.split(separator: "-").compactMap { + Int($0) + } + if parts.count < 2 { + return nil + } + self.init(month: parts[0], day: parts[1]) + } + + var description: String { + return String(format: "%02d-%02d", self.month, self.day) + } +} diff --git a/Twigs/Recurring Transactions/RecurringTransactionDataStore.swift b/Twigs/Recurring Transactions/RecurringTransactionDataStore.swift new file mode 100644 index 0000000..c719bba --- /dev/null +++ b/Twigs/Recurring Transactions/RecurringTransactionDataStore.swift @@ -0,0 +1,111 @@ +// +// RecurringTransactionDataStore.swift +// Twigs +// +// Created by William Brawner on 12/6/21. +// Copyright © 2021 William Brawner. All rights reserved. +// + +import Foundation +import Combine +import Collections + +class RecurringTransactionDataStore: ObservableObject { + private let repository: RecurringTransactionsRepository + private var currentRequest: AnyCancellable? = nil + @Published var transactions: Result<[RecurringTransaction], NetworkError>? = nil + @Published var transaction: Result? = nil + + init(_ repository: RecurringTransactionsRepository, budgetId: String) { + self.repository = repository + getRecurringTransactions(budgetId) + } + + func getRecurringTransactions(_ budgetId: String) { + self.transactions = .failure(.loading) + self.currentRequest = self.repository.getRecurringTransactions(budgetId: budgetId) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { (completion) in + switch completion { + case .finished: + self.currentRequest = nil + return + case .failure(let error): + print("Error loading recurring transactions: \(error.name)") + self.transactions = .failure(error) + } + }, receiveValue: { (transactions) in + self.transactions = .success(transactions.sorted(by: { $0.title < $1.title })) + }) + } + + func getRecurringTransaction(_ id: String) { + self.transaction = .failure(.loading) + + self.currentRequest = self.repository.getRecurringTransaction(id) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { (completion) in + switch completion { + case .finished: + self.currentRequest = nil + return + case .failure(let error): + self.transaction = .failure(error) + } + }, receiveValue: { (transaction) in + self.transaction = .success(transaction) + }) + } + + func saveRecurringTransaction(_ transaction: RecurringTransaction) { + self.transaction = .failure(.loading) + var transactionSavePublisher: AnyPublisher + if (transaction.id != "") { + transactionSavePublisher = self.repository.updateRecurringTransaction(transaction) + } else { + transactionSavePublisher = self.repository.createRecurringTransaction(transaction) + } + self.currentRequest = transactionSavePublisher + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { (completion) in + switch completion { + case .finished: + self.currentRequest = nil + return + case .failure(let error): + self.transaction = .failure(error) + } + }, receiveValue: { (transaction) in + self.transaction = .success(transaction) + if case var .success(transactions) = self.transactions { + transactions.insert(transaction, at: 0) + self.transactions = .success(transactions) + } + }) + } + + func deleteRecurringTransaction(_ id: String) { + self.transaction = .failure(.loading) + + self.currentRequest = self.repository.deleteRecurringTransaction(id) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { completion in + switch completion { + case .finished: + self.currentRequest = nil + return + case .failure(let error): + self.transaction = .failure(error) + } + }, receiveValue: { (empty) in + self.transaction = .failure(.deleted) + if case let .success(transactions) = self.transactions { + self.transactions = .success(transactions.filter { $0.id != id }) + } + }) + } + + func clearSelectedRecurringTransaction() { + self.transaction = nil + } +} diff --git a/Twigs/Recurring Transactions/RecurringTransactionDetailsView.swift b/Twigs/Recurring Transactions/RecurringTransactionDetailsView.swift new file mode 100644 index 0000000..037a5a5 --- /dev/null +++ b/Twigs/Recurring Transactions/RecurringTransactionDetailsView.swift @@ -0,0 +1,47 @@ +// +// RecurringTransactionDetailsView.swift +// Twigs +// +// Created by William Brawner on 12/7/21. +// Copyright © 2021 William Brawner. All rights reserved. +// + +import SwiftUI + +struct RecurringTransactionDetailsView: View { + let transaction: RecurringTransaction + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + Text(transaction.title) + .font(.title) + Text(transaction.amount.toCurrencyString()) + .font(.headline) + .foregroundColor(transaction.expense ? .red : .green) + .multilineTextAlignment(.trailing) + if let description = transaction.description { + Text(description) + } + Spacer().frame(height: 10) + LabeledField(label: "start", value: transaction.start.toLocaleString(), showDivider: true) + LabeledField(label: "end", value: transaction.end?.toLocaleString(), showDivider: true) + CategoryLineItem(transaction.categoryId) + BudgetLineItem() + UserLineItem(transaction.createdBy) + }.padding() + } + } + + init(_ transaction: RecurringTransaction) { + self.transaction = transaction + } +} + +#if DEBUG +struct RecurringTransactionDetailsView_Previews: PreviewProvider { + static var previews: some View { + RecurringTransactionDetailsView(MockRecurringTransactionRepository.transaction) + } +} +#endif diff --git a/Twigs/Recurring Transactions/RecurringTransactionsListView.swift b/Twigs/Recurring Transactions/RecurringTransactionsListView.swift new file mode 100644 index 0000000..f9f1930 --- /dev/null +++ b/Twigs/Recurring Transactions/RecurringTransactionsListView.swift @@ -0,0 +1,75 @@ +// +// RecurringTransactionView.swift +// Twigs +// +// Created by William Brawner on 12/6/21. +// Copyright © 2021 William Brawner. All rights reserved. +// + +import SwiftUI + +struct RecurringTransactionsListView: View { + @ObservedObject var dataStore: RecurringTransactionDataStore + + var body: some View { + switch dataStore.transactions { + case .success(let transactions): + List { + ForEach(transactions) { transaction in + RecurringTransactionsListItemView(transaction) + } + } + default: + ActivityIndicator(isAnimating: .constant(true), style: .medium) + } + } +} + +struct RecurringTransactionView_Previews: PreviewProvider { + static var previews: some View { + RecurringTransactionsListView(dataStore: RecurringTransactionDataStore(MockRecurringTransactionRepository(), budgetId: "")) + } +} + +struct RecurringTransactionsListItemView: View { + let transaction: RecurringTransaction + + init (_ transaction: RecurringTransaction) { + self.transaction = transaction + } + + var body: some View { + NavigationLink( + destination: RecurringTransactionDetailsView(transaction) + .navigationBarTitle("details", displayMode: .inline) + ) { + HStack { + VStack(alignment: .leading) { + Text(verbatim: transaction.title) + .lineLimit(1) + .font(.headline) + if let description = transaction.description?.trimmingCharacters(in: CharacterSet([" "])), !description.isEmpty { + Text(verbatim: description) + .lineLimit(1) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.trailing) + } + } + Spacer() + VStack(alignment: .trailing) { + Text(verbatim: transaction.amount.toCurrencyString()) + .foregroundColor(transaction.expense ? .red : .green) + .multilineTextAlignment(.trailing) + } + .padding(.leading) + }.padding(5.0) + } + } +} + +struct RecurringTransactionsListItemView_Previews: PreviewProvider { + static var previews: some View { + RecurringTransactionsListItemView(MockRecurringTransactionRepository.transaction) + } +} diff --git a/Twigs/Recurring Transactions/RecurringTransactionsRepository.swift b/Twigs/Recurring Transactions/RecurringTransactionsRepository.swift new file mode 100644 index 0000000..f3aae0e --- /dev/null +++ b/Twigs/Recurring Transactions/RecurringTransactionsRepository.swift @@ -0,0 +1,56 @@ +// +// RecurringTransactionsRepository.swift +// Twigs +// +// Created by William Brawner on 12/6/21. +// Copyright © 2021 William Brawner. All rights reserved. +// + +import Foundation +import Combine + +protocol RecurringTransactionsRepository { + func getRecurringTransactions(budgetId: String) -> AnyPublisher<[RecurringTransaction], NetworkError> + func getRecurringTransaction(_ id: String) -> AnyPublisher + func createRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher + func updateRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher + func deleteRecurringTransaction(_ id: String) -> AnyPublisher +} + +#if DEBUG +class MockRecurringTransactionRepository: RecurringTransactionsRepository { + static let transaction: RecurringTransaction = RecurringTransaction( + id: "2", + title: "Test Transaction", + description: "A mock transaction used for testing", + frequency: Frequency(unit: .daily, count: 1, time: Time(from: "09:00:00")!)!, + start: Date(), + end: nil, + amount: 10000, + categoryId: MockCategoryRepository.category.id, + expense: true, + createdBy: MockUserRepository.user.id, + budgetId: MockBudgetRepository.budget.id + ) + + func getRecurringTransactions(budgetId: String) -> AnyPublisher<[RecurringTransaction], NetworkError> { + return Result.Publisher([MockRecurringTransactionRepository.transaction]).eraseToAnyPublisher() + } + + func getRecurringTransaction(_ id: String) -> AnyPublisher { + return Result.Publisher(MockRecurringTransactionRepository.transaction).eraseToAnyPublisher() + } + + func createRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher { + return Result.Publisher(MockRecurringTransactionRepository.transaction).eraseToAnyPublisher() + } + + func updateRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher { + return Result.Publisher(MockRecurringTransactionRepository.transaction).eraseToAnyPublisher() + } + + func deleteRecurringTransaction(_ id: String) -> AnyPublisher { + return Result.Publisher(.success(Empty())).eraseToAnyPublisher() + } +} +#endif diff --git a/Twigs/TabbedBudgetView.swift b/Twigs/TabbedBudgetView.swift index 515b9e5..9ba97bf 100644 --- a/Twigs/TabbedBudgetView.swift +++ b/Twigs/TabbedBudgetView.swift @@ -12,6 +12,7 @@ struct TabbedBudgetView: View { @EnvironmentObject var authenticationDataStore: AuthenticationDataStore @EnvironmentObject var budgetDataStore: BudgetsDataStore @EnvironmentObject var categoryDataStore: CategoryDataStore + let apiService: TwigsApiService @State var isSelectingBudget = true @State var hasSelectedBudget = false @State var isAddingTransaction = false @@ -65,12 +66,12 @@ struct TabbedBudgetView: View { .tag(2) .keyboardShortcut("3") NavigationView { - ProfileView() - .navigationBarTitle("profile") + RecurringTransactionsListView(dataStore: RecurringTransactionDataStore(apiService, budgetId: budget.id)) + .navigationBarTitle("recurring_transactions") } .tabItem { - Image(systemName: "person.circle.fill") - Text("profile") + Image(systemName: "arrow.triangle.2.circlepath.circle.fill") + Text("recurring") } .tag(3) .keyboardShortcut("4") diff --git a/Twigs/en.lproj/Localizable.strings b/Twigs/en.lproj/Localizable.strings index c386389..11f11c5 100644 --- a/Twigs/en.lproj/Localizable.strings +++ b/Twigs/en.lproj/Localizable.strings @@ -73,3 +73,7 @@ "change_password" = "Change Password"; "change_email" = "Change Email"; "delete_account" = "Delete Account"; + +// MARK: Recurring Transactions +"recurring" = "Recurring"; +"recurring_transactions" = "Recurring Transactions"; diff --git a/Twigs/es.lproj/Localizable.strings b/Twigs/es.lproj/Localizable.strings index fee4806..a690c26 100644 --- a/Twigs/es.lproj/Localizable.strings +++ b/Twigs/es.lproj/Localizable.strings @@ -74,3 +74,7 @@ "change_password" = "Cambiar Contraseña"; "change_email" = "Cambiar Correo"; "delete_account" = "Eliminar Cuenta"; + +// MARK: Recurring Transactions +"recurring" = "Recurrentes"; +"recurring_transactions" = "Transacciones Recurrentes";