Add read-only view of recurring transactions
This commit is contained in:
parent
733f1cc764
commit
b035b027ab
13 changed files with 618 additions and 14 deletions
|
@ -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 = "<group>"; };
|
||||
28FE6B0523444A9800D5543E /* TransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDetailsView.swift; sourceTree = "<group>"; };
|
||||
543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationDataStore.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>"; };
|
||||
801D08CF275F1AE300931465 /* RecurringTransactionDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionDataStore.swift; sourceTree = "<group>"; };
|
||||
801D08D1275FB7DE00931465 /* RecurringTransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionDetailsView.swift; sourceTree = "<group>"; };
|
||||
8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailsView.swift; sourceTree = "<group>"; };
|
||||
806C784F272B700B00FA1375 /* TwigsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsApp.swift; sourceTree = "<group>"; };
|
||||
80820144275FFD380040996E /* SidebarBudgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarBudgetView.swift; sourceTree = "<group>"; };
|
||||
809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryFormSheet.swift; sourceTree = "<group>"; };
|
||||
809B94242722597800B1DAE2 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
80D6B1F0275B11DE0075D0EC /* RecurringTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransaction.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
/* 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 = "";
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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<Empty, NetworkError> {
|
||||
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<RecurringTransaction, NetworkError> {
|
||||
return requestHelper.get("/api/recurringtransactions/\(id)")
|
||||
}
|
||||
|
||||
func createRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher<RecurringTransaction, NetworkError> {
|
||||
return requestHelper.post("/api/recurringtransactions", data: transaction, type: RecurringTransaction.self)
|
||||
}
|
||||
|
||||
func updateRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher<RecurringTransaction, NetworkError> {
|
||||
return requestHelper.put("/api/recurringtransactions/\(transaction.id)", data: transaction)
|
||||
}
|
||||
|
||||
func deleteRecurringTransaction(_ id: String) -> AnyPublisher<Empty, NetworkError> {
|
||||
return requestHelper.delete("/api/recurringtransactions/\(id)")
|
||||
}
|
||||
}
|
||||
|
||||
private let BASE_URL = "BASE_URL"
|
||||
|
|
259
Twigs/Recurring Transactions/RecurringTransaction.swift
Normal file
259
Twigs/Recurring Transactions/RecurringTransaction.swift
Normal file
|
@ -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<DayOfWeek>)
|
||||
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)
|
||||
}
|
||||
}
|
111
Twigs/Recurring Transactions/RecurringTransactionDataStore.swift
Normal file
111
Twigs/Recurring Transactions/RecurringTransactionDataStore.swift
Normal file
|
@ -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<RecurringTransaction, NetworkError>? = 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<RecurringTransaction, NetworkError>
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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<RecurringTransaction, NetworkError>
|
||||
func createRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher<RecurringTransaction, NetworkError>
|
||||
func updateRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher<RecurringTransaction, NetworkError>
|
||||
func deleteRecurringTransaction(_ id: String) -> AnyPublisher<Empty, NetworkError>
|
||||
}
|
||||
|
||||
#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<RecurringTransaction, NetworkError> {
|
||||
return Result.Publisher(MockRecurringTransactionRepository.transaction).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func createRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher<RecurringTransaction, NetworkError> {
|
||||
return Result.Publisher(MockRecurringTransactionRepository.transaction).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func updateRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher<RecurringTransaction, NetworkError> {
|
||||
return Result.Publisher(MockRecurringTransactionRepository.transaction).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func deleteRecurringTransaction(_ id: String) -> AnyPublisher<Empty, NetworkError> {
|
||||
return Result.Publisher(.success(Empty())).eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
#endif
|
|
@ -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")
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
Loading…
Reference in a new issue