Implement creation/editing of recurring transactions

This commit is contained in:
William Brawner 2022-05-28 22:25:12 -06:00
parent 94272aa7f0
commit e621f659c8
14 changed files with 905 additions and 22 deletions

View file

@ -47,14 +47,20 @@
806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806C784F272B700B00FA1375 /* TwigsApp.swift */; }; 806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806C784F272B700B00FA1375 /* TwigsApp.swift */; };
8076A84F2809FE8E006B9DC9 /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = 8076A84E2809FE8E006B9DC9 /* ArgumentParser */; }; 8076A84F2809FE8E006B9DC9 /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = 8076A84E2809FE8E006B9DC9 /* ArgumentParser */; };
8076A8522809FE99006B9DC9 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 8076A8512809FE99006B9DC9 /* Collections */; }; 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 */; }; 80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80820144275FFD380040996E /* SidebarBudgetView.swift */; };
808CA1A728354005002EDD59 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 808CA1A628354005002EDD59 /* XCTest.framework */; }; 808CA1A728354005002EDD59 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 808CA1A628354005002EDD59 /* XCTest.framework */; };
808CA1A928355B30002EDD59 /* BudgetFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808CA1A828355B30002EDD59 /* BudgetFormView.swift */; }; 808CA1A928355B30002EDD59 /* BudgetFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808CA1A828355B30002EDD59 /* BudgetFormView.swift */; };
809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */; }; 809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */; };
80A419ED2787C0A00090C515 /* TwigsCli.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A419EC2787C0A00090C515 /* TwigsCli.swift */; }; 80A419ED2787C0A00090C515 /* TwigsCli.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A419EC2787C0A00090C515 /* TwigsCli.swift */; };
80A419F52787C1520090C515 /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = 80A419F42787C1520090C515 /* ArgumentParser */; }; 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 */; }; 80D1FC14277C1EF9007F17FB /* InlineLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D1FC13277C1EF9007F17FB /* InlineLoadingView.swift */; };
80D2CE1A2833448500EDD6C2 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D2CE192833448500EDD6C2 /* DataStore.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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -132,6 +138,8 @@
8044BA3C2784CC0D009A78D4 /* TransactionForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionForm.swift; sourceTree = "<group>"; }; 8044BA3C2784CC0D009A78D4 /* TransactionForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionForm.swift; sourceTree = "<group>"; };
8044BA3E27853054009A78D4 /* CategoryForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryForm.swift; sourceTree = "<group>"; }; 8044BA3E27853054009A78D4 /* CategoryForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryForm.swift; sourceTree = "<group>"; };
806C784F272B700B00FA1375 /* TwigsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsApp.swift; sourceTree = "<group>"; }; 806C784F272B700B00FA1375 /* TwigsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsApp.swift; sourceTree = "<group>"; };
807FEAB42837F71200D05338 /* RecurringTransactionForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionForm.swift; sourceTree = "<group>"; };
807FEAB62838042500D05338 /* MultiPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiPicker.swift; sourceTree = "<group>"; };
80820144275FFD380040996E /* SidebarBudgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarBudgetView.swift; sourceTree = "<group>"; }; 80820144275FFD380040996E /* SidebarBudgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarBudgetView.swift; sourceTree = "<group>"; };
808CA1A628354005002EDD59 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; 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 = "<group>"; }; 808CA1A828355B30002EDD59 /* BudgetFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetFormView.swift; sourceTree = "<group>"; };
@ -139,8 +147,12 @@
809B94242722597800B1DAE2 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; }; 809B94242722597800B1DAE2 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
80A419EA2787C0A00090C515 /* twigs-cli */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "twigs-cli"; sourceTree = BUILT_PRODUCTS_DIR; }; 80A419EA2787C0A00090C515 /* twigs-cli */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "twigs-cli"; sourceTree = BUILT_PRODUCTS_DIR; };
80A419EC2787C0A00090C515 /* TwigsCli.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsCli.swift; sourceTree = "<group>"; }; 80A419EC2787C0A00090C515 /* TwigsCli.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsCli.swift; sourceTree = "<group>"; };
80AF7A972835ED3B009565C6 /* RecurringTransactionFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionFormView.swift; sourceTree = "<group>"; };
80D1FC13277C1EF9007F17FB /* InlineLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineLoadingView.swift; sourceTree = "<group>"; }; 80D1FC13277C1EF9007F17FB /* InlineLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineLoadingView.swift; sourceTree = "<group>"; };
80D2CE192833448500EDD6C2 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; }; 80D2CE192833448500EDD6C2 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
80FC1BBD28411DD800682F21 /* YearlyFrequencyPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YearlyFrequencyPicker.swift; sourceTree = "<group>"; };
80FC1BBF284146A000682F21 /* WeeklyFrequencyPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeeklyFrequencyPicker.swift; sourceTree = "<group>"; };
80FC1BC1284146CD00682F21 /* MonthlyFrequencyPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonthlyFrequencyPicker.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -219,6 +231,10 @@
282126BC235CDE1400072D52 /* ProgressView.swift */, 282126BC235CDE1400072D52 /* ProgressView.swift */,
80D1FC13277C1EF9007F17FB /* InlineLoadingView.swift */, 80D1FC13277C1EF9007F17FB /* InlineLoadingView.swift */,
8005FD5C277EAB0200E48B23 /* MainView.swift */, 8005FD5C277EAB0200E48B23 /* MainView.swift */,
807FEAB62838042500D05338 /* MultiPicker.swift */,
80FC1BBD28411DD800682F21 /* YearlyFrequencyPicker.swift */,
80FC1BBF284146A000682F21 /* WeeklyFrequencyPicker.swift */,
80FC1BC1284146CD00682F21 /* MonthlyFrequencyPicker.swift */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
@ -355,6 +371,8 @@
801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */, 801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */,
801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */, 801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */,
801D08D1275FB7DE00931465 /* RecurringTransactionDetailsView.swift */, 801D08D1275FB7DE00931465 /* RecurringTransactionDetailsView.swift */,
80AF7A972835ED3B009565C6 /* RecurringTransactionFormView.swift */,
807FEAB42837F71200D05338 /* RecurringTransactionForm.swift */,
); );
path = "Recurring Transactions"; path = "Recurring Transactions";
sourceTree = "<group>"; sourceTree = "<group>";
@ -538,6 +556,7 @@
files = ( files = (
2821266023555FD300072D52 /* TransactionFormSheet.swift in Sources */, 2821266023555FD300072D52 /* TransactionFormSheet.swift in Sources */,
8044BA3D2784CC0D009A78D4 /* TransactionForm.swift in Sources */, 8044BA3D2784CC0D009A78D4 /* TransactionForm.swift in Sources */,
80FC1BC0284146A000682F21 /* WeeklyFrequencyPicker.swift in Sources */,
28FE6AFC23441E4500D5543E /* CategoryRepository.swift in Sources */, 28FE6AFC23441E4500D5543E /* CategoryRepository.swift in Sources */,
801D08CE275F189E00931465 /* RecurringTransactionsRepository.swift in Sources */, 801D08CE275F189E00931465 /* RecurringTransactionsRepository.swift in Sources */,
2841022723419A2B00EAFA29 /* TabbedBudgetView.swift in Sources */, 2841022723419A2B00EAFA29 /* TabbedBudgetView.swift in Sources */,
@ -547,6 +566,7 @@
28AC952C233C434800BFB70A /* UserRepository.swift in Sources */, 28AC952C233C434800BFB70A /* UserRepository.swift in Sources */,
28FE6B04234449DC00D5543E /* TransactionListView.swift in Sources */, 28FE6B04234449DC00D5543E /* TransactionListView.swift in Sources */,
28AC94F2233C373900BFB70A /* LoginView.swift in Sources */, 28AC94F2233C373900BFB70A /* LoginView.swift in Sources */,
80FC1BC2284146CD00682F21 /* MonthlyFrequencyPicker.swift in Sources */,
802161D0277647920075761A /* AsyncObservableObject.swift in Sources */, 802161D0277647920075761A /* AsyncObservableObject.swift in Sources */,
282126BB235CDD3C00072D52 /* BudgetDetailsView.swift in Sources */, 282126BB235CDD3C00072D52 /* BudgetDetailsView.swift in Sources */,
80D2CE1A2833448500EDD6C2 /* DataStore.swift in Sources */, 80D2CE1A2833448500EDD6C2 /* DataStore.swift in Sources */,
@ -562,14 +582,18 @@
282126A1235929B800072D52 /* ProfileView.swift in Sources */, 282126A1235929B800072D52 /* ProfileView.swift in Sources */,
28AC9529233C433400BFB70A /* TransactionRepository.swift in Sources */, 28AC9529233C433400BFB70A /* TransactionRepository.swift in Sources */,
809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */, 809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */,
807FEAB72838042500D05338 /* MultiPicker.swift in Sources */,
28FE6AF62342E4CC00D5543E /* BudgetRepository.swift in Sources */, 28FE6AF62342E4CC00D5543E /* BudgetRepository.swift in Sources */,
80FC1BBE28411DD800682F21 /* YearlyFrequencyPicker.swift in Sources */,
8044BA3F27853054009A78D4 /* CategoryForm.swift in Sources */, 8044BA3F27853054009A78D4 /* CategoryForm.swift in Sources */,
807FEAB52837F71200D05338 /* RecurringTransactionForm.swift in Sources */,
80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */, 80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */,
8044BA3927828E9D009A78D4 /* CategoryDataStore.swift in Sources */, 8044BA3927828E9D009A78D4 /* CategoryDataStore.swift in Sources */,
284102302342D97300EAFA29 /* BudgetListsView.swift in Sources */, 284102302342D97300EAFA29 /* BudgetListsView.swift in Sources */,
282126BD235CDE1400072D52 /* ProgressView.swift in Sources */, 282126BD235CDE1400072D52 /* ProgressView.swift in Sources */,
806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */, 806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */,
808CA1A928355B30002EDD59 /* BudgetFormView.swift in Sources */, 808CA1A928355B30002EDD59 /* BudgetFormView.swift in Sources */,
80AF7A982835ED3B009565C6 /* RecurringTransactionFormView.swift in Sources */,
28CE8B9523525F990072BC4C /* Extensions.swift in Sources */, 28CE8B9523525F990072BC4C /* Extensions.swift in Sources */,
801D08CC275ECEFA00931465 /* RecurringTransactionsListView.swift in Sources */, 801D08CC275ECEFA00931465 /* RecurringTransactionsListView.swift in Sources */,
); );

View file

@ -33,6 +33,37 @@ class DataStore : ObservableObject {
@Published var showBudgetSelection: Bool = true @Published var showBudgetSelection: Bool = true
@Published var editingBudget: Bool = false @Published var editingBudget: Bool = false
@Published var editingCategory: 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( init(
_ apiService: TwigsApiService _ apiService: TwigsApiService
@ -261,8 +292,12 @@ class DataStore : ObservableObject {
didSet { didSet {
if case let .success(transaction) = self.recurringTransaction { if case let .success(transaction) = self.recurringTransaction {
self.selectedRecurringTransaction = transaction self.selectedRecurringTransaction = transaction
self.editingRecurringTransaction = false
} else if case .empty = recurringTransaction { } else if case .empty = recurringTransaction {
self.selectedRecurringTransaction = nil 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 { func saveRecurringTransaction(_ transaction: RecurringTransaction) async {
self.recurringTransaction = .loading self.recurringTransaction = .loading
do { do {
@ -329,20 +389,12 @@ class DataStore : ObservableObject {
} }
} }
@Published var selectedTransaction: Transaction? = nil @Published var selectedTransaction: Transaction? = nil
private var budgetId: String = ""
private var categoryId: String? = nil
func getTransactions() async { func getTransactions() async {
guard case let .success(budget) = self.budget else { guard let budgetId = self.budgetId else {
self.transactions = .error(NetworkError.unknown) self.transactions = .error(NetworkError.unknown)
return return
} }
self.budgetId = budget.id
if case let .success(category) = self.category {
self.categoryId = category.id
} else {
self.categoryId = nil
}
self.transactions = .loading self.transactions = .loading
do { do {
var categoryIds: [String] = [] var categoryIds: [String] = []

View file

@ -28,12 +28,23 @@ struct RecurringTransactionDetailsView: View {
Text(transaction.frequency.naturalDescription) Text(transaction.frequency.naturalDescription)
Spacer().frame(height: 10) Spacer().frame(height: 10)
LabeledField(label: "start", value: transaction.start.toLocaleString(), loading: .constant(false), showDivider: true) 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() // CategoryLineItem()
// BudgetLineItem() // BudgetLineItem()
// UserLineItem() // UserLineItem()
}.padding() }.padding()
} }
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
Task {
await dataStore.edit(transaction)
}
}) {
Text("edit")
}
}
}
} }
} }
} }

View file

@ -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<DayOfWeek>
@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"
}

View file

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

View file

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

View file

@ -18,7 +18,7 @@ class MockRecurringTransactionRepository: RecurringTransactionsRepository {
description: "A mock transaction used for testing", description: "A mock transaction used for testing",
frequency: Frequency(unit: .daily, count: 1, time: Time(from: "09:00:00")!)!, frequency: Frequency(unit: .daily, count: 1, time: Time(from: "09:00:00")!)!,
start: Date(), start: Date(),
end: nil, finish: nil,
amount: 10000, amount: 10000,
categoryId: MockCategoryRepository.category.id, categoryId: MockCategoryRepository.category.id,
expense: true, expense: true,

View file

@ -34,8 +34,8 @@ struct TransactionFormSheet: View {
Text(type.localizedKey) Text(type.localizedKey)
} }
} }
BudgetPicker() BudgetPicker(budgets: $transactionForm.budgets, budgetId: $transactionForm.budgetId)
CategoryPicker() CategoryPicker(categories: $transactionForm.categories, categoryId: $transactionForm.categoryId)
if transactionForm.showDelete { if transactionForm.showDelete {
Button(action: { Button(action: {
self.showingAlert = true self.showingAlert = true
@ -76,18 +76,19 @@ struct TransactionFormSheet: View {
} }
struct BudgetPicker: View { struct BudgetPicker: View {
@EnvironmentObject var transactionForm: TransactionForm @Binding var budgets: AsyncData<[Budget]>
@Binding var budgetId: String
@ViewBuilder @ViewBuilder
var body: some View { var body: some View {
if case let .success(budgets) = self.transactionForm.budgets { if case let .success(budgets) = budgets {
Picker(LocalizedStringKey("prompt_budget"), selection: $transactionForm.budgetId) { Picker(LocalizedStringKey("prompt_budget"), selection: $budgetId) {
ForEach(budgets) { budget in ForEach(budgets) { budget in
Text(budget.name) Text(budget.name)
} }
} }
} else { } else {
Picker(LocalizedStringKey("prompt_budget"), selection: $transactionForm.budgetId) { Picker(LocalizedStringKey("prompt_budget"), selection: $budgetId) {
Text("") Text("")
} }
} }
@ -95,14 +96,15 @@ struct BudgetPicker: View {
} }
struct CategoryPicker: View { struct CategoryPicker: View {
@EnvironmentObject var transactionForm: TransactionForm @Binding var categories: AsyncData<[TwigsCore.Category]>
@Binding var categoryId: String
@ViewBuilder @ViewBuilder
var body: some View { var body: some View {
if case let .success(categories) = self.transactionForm.categories { if case let .success(categories) = categories {
Picker(LocalizedStringKey("prompt_category"), selection: $transactionForm.categoryId) { Picker(LocalizedStringKey("prompt_category"), selection: $categoryId) {
ForEach(categories) { category in ForEach(categories) { category in
Text(category.title) Text(category.title).tag(category.id)
} }
} }
} else { } else {

View file

@ -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<DayOfMonth>) {
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<MonthlyFrequencyPicker>) -> UIPickerView {
let picker = UIPickerView(frame: .zero)
picker.dataSource = context.coordinator
picker.delegate = context.coordinator
return picker
}
func updateUIView(_ view: UIPickerView, context: UIViewRepresentableContext<MonthlyFrequencyPicker>) {
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<Int>, selectedDay: Binding<Int>, selectedDayOfWeek: Binding<Int>) {
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)
}
}

View file

@ -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<MultiPicker>) -> UIPickerView {
let picker = UIPickerView(frame: .zero)
picker.dataSource = context.coordinator
picker.delegate = context.coordinator
return picker
}
func updateUIView(_ view: UIPickerView, context: UIViewRepresentableContext<MultiPicker>) {
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)
}
}

View file

@ -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<DayOfWeek>
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<DayOfWeek> = Set()
static var previews: some View {
WeeklyFrequencyPicker(selection: $selection)
}
}

View file

@ -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<DayOfYear>) {
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<YearlyFrequencyPicker>) -> UIPickerView {
let picker = UIPickerView(frame: .zero)
picker.dataSource = context.coordinator
picker.delegate = context.coordinator
return picker
}
func updateUIView(_ view: UIPickerView, context: UIViewRepresentableContext<YearlyFrequencyPicker>) {
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<Int>, selectedDay: Binding<Int> ) {
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)
}
}

View file

@ -80,3 +80,54 @@
// MARK: Recurring Transactions // MARK: Recurring Transactions
"recurring" = "Recurring"; "recurring" = "Recurring";
"recurring_transactions" = "Recurring Transactions"; "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";

View file

@ -81,3 +81,54 @@
// MARK: Recurring Transactions // MARK: Recurring Transactions
"recurring" = "Recurrentes"; "recurring" = "Recurrentes";
"recurring_transactions" = "Transacciones 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";