WIP: Add budgets screen

Signed-off-by: Billy Brawner <billy@wbrawner.com>
This commit is contained in:
Billy Brawner 2019-09-30 20:31:22 -07:00
parent 1053fa91f8
commit 56488bd265
16 changed files with 317 additions and 54 deletions

View file

@ -9,6 +9,9 @@
/* Begin PBXBuildFile section */
284102252341998300EAFA29 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 284102242341998300EAFA29 /* ContentView.swift */; };
2841022723419A2B00EAFA29 /* TabbedBudgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2841022623419A2B00EAFA29 /* TabbedBudgetView.swift */; };
2841022C2342D8E400EAFA29 /* Budget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2841022B2342D8E400EAFA29 /* Budget.swift */; };
284102302342D97300EAFA29 /* BudgetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2841022F2342D97300EAFA29 /* BudgetsView.swift */; };
284102322342E12F00EAFA29 /* CategoriesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 284102312342E12F00EAFA29 /* CategoriesView.swift */; };
2857EAED233DA30B0026BC83 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2857EAEC233DA30B0026BC83 /* LoadingView.swift */; };
28AC94EE233C373900BFB70A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC94ED233C373900BFB70A /* AppDelegate.swift */; };
28AC94F0233C373900BFB70A /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC94EF233C373900BFB70A /* SceneDelegate.swift */; };
@ -23,6 +26,8 @@
28AC9529233C433400BFB70A /* TransactionRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC9528233C433400BFB70A /* TransactionRepository.swift */; };
28AC952C233C434800BFB70A /* UserRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC952B233C434800BFB70A /* UserRepository.swift */; };
28AC952E233C43A300BFB70A /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC952D233C43A300BFB70A /* User.swift */; };
28FE6AF42342E3CB00D5543E /* BudgetsDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AF32342E3CB00D5543E /* BudgetsDataStore.swift */; };
28FE6AF62342E4CC00D5543E /* BudgetRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AF52342E4CC00D5543E /* BudgetRepository.swift */; };
543ECE42233E82A40018A9D9 /* UserDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543ECE41233E82A40018A9D9 /* UserDataStore.swift */; };
/* End PBXBuildFile section */
@ -46,6 +51,9 @@
/* Begin PBXFileReference section */
284102242341998300EAFA29 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
2841022623419A2B00EAFA29 /* TabbedBudgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedBudgetView.swift; sourceTree = "<group>"; };
2841022B2342D8E400EAFA29 /* Budget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Budget.swift; sourceTree = "<group>"; };
2841022F2342D97300EAFA29 /* BudgetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetsView.swift; sourceTree = "<group>"; };
284102312342E12F00EAFA29 /* CategoriesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoriesView.swift; sourceTree = "<group>"; };
2857EAEC233DA30B0026BC83 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
28AC94EA233C373900BFB70A /* Budget.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Budget.app; sourceTree = BUILT_PRODUCTS_DIR; };
28AC94ED233C373900BFB70A /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@ -68,6 +76,8 @@
28AC9528233C433400BFB70A /* TransactionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionRepository.swift; sourceTree = "<group>"; };
28AC952B233C434800BFB70A /* UserRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepository.swift; sourceTree = "<group>"; };
28AC952D233C43A300BFB70A /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; };
28FE6AF32342E3CB00D5543E /* BudgetsDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetsDataStore.swift; sourceTree = "<group>"; };
28FE6AF52342E4CC00D5543E /* BudgetRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetRepository.swift; sourceTree = "<group>"; };
543ECE41233E82A40018A9D9 /* UserDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDataStore.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -96,6 +106,25 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
284102292342D8BB00EAFA29 /* Budget */ = {
isa = PBXGroup;
children = (
2841022F2342D97300EAFA29 /* BudgetsView.swift */,
2841022B2342D8E400EAFA29 /* Budget.swift */,
28FE6AF32342E3CB00D5543E /* BudgetsDataStore.swift */,
28FE6AF52342E4CC00D5543E /* BudgetRepository.swift */,
);
path = Budget;
sourceTree = "<group>";
};
2841022A2342D8CB00EAFA29 /* Category */ = {
isa = PBXGroup;
children = (
284102312342E12F00EAFA29 /* CategoriesView.swift */,
);
path = Category;
sourceTree = "<group>";
};
2857EAEB233DA2F90026BC83 /* Views */ = {
isa = PBXGroup;
children = (
@ -127,6 +156,8 @@
28AC94EC233C373900BFB70A /* Budget */ = {
isa = PBXGroup;
children = (
2841022A2342D8CB00EAFA29 /* Category */,
284102292342D8BB00EAFA29 /* Budget */,
2857EAEB233DA2F90026BC83 /* Views */,
28AC952A233C433C00BFB70A /* User */,
28AC9527233C430A00BFB70A /* Network */,
@ -332,14 +363,19 @@
files = (
28AC94EE233C373900BFB70A /* AppDelegate.swift in Sources */,
28AC94F0233C373900BFB70A /* SceneDelegate.swift in Sources */,
2841022C2342D8E400EAFA29 /* Budget.swift in Sources */,
2841022723419A2B00EAFA29 /* TabbedBudgetView.swift in Sources */,
28FE6AF42342E3CB00D5543E /* BudgetsDataStore.swift in Sources */,
28AC952C233C434800BFB70A /* UserRepository.swift in Sources */,
28AC94F2233C373900BFB70A /* LoginView.swift in Sources */,
28AC9525233C42D100BFB70A /* BudgetApiService.swift in Sources */,
2857EAED233DA30B0026BC83 /* LoadingView.swift in Sources */,
284102322342E12F00EAFA29 /* CategoriesView.swift in Sources */,
284102252341998300EAFA29 /* ContentView.swift in Sources */,
28AC9529233C433400BFB70A /* TransactionRepository.swift in Sources */,
28FE6AF62342E4CC00D5543E /* BudgetRepository.swift in Sources */,
543ECE42233E82A40018A9D9 /* UserDataStore.swift in Sources */,
284102302342D97300EAFA29 /* BudgetsView.swift in Sources */,
28AC952E233C43A300BFB70A /* User.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View file

@ -16,7 +16,7 @@
endingColumnNumber = "9223372036854775807"
startingLineNumber = "116"
endingLineNumber = "116"
landmarkName = "buildRequest(endPoint:method:data:)"
landmarkName = "post(endPoint:data:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
@ -32,7 +32,7 @@
endingColumnNumber = "9223372036854775807"
startingLineNumber = "36"
endingLineNumber = "36"
landmarkName = "login(username:password:)"
landmarkName = "init(_:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>

View file

@ -0,0 +1,16 @@
//
// Budget.swift
// Budget
//
// Created by Billy Brawner on 9/30/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
import Foundation
struct Budget: Identifiable, Codable {
let id: Int?
let name: String
let description: String?
let users: [User]
}

View file

@ -0,0 +1,22 @@
//
// BudgetRepository.swift
// Budget
//
// Created by Billy Brawner on 9/30/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
import Foundation
import Combine
class BudgetRepository {
let apiService: BudgetApiService
init(_ apiService: BudgetApiService) {
self.apiService = apiService
}
func getBudgets() -> AnyPublisher<[Budget], NetworkError> {
return apiService.getBudgets()
}
}

View file

@ -0,0 +1,44 @@
//
// BudgetsDataStore.swift
// Budget
//
// Created by Billy Brawner on 9/30/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
import Foundation
import Combine
class BudgetsDataStore: ObservableObject {
var budgets: Result<[Budget], NetworkError> = .failure(.loading) {
didSet {
self.objectWillChange.send()
}
}
func getBudgets() {
self.budgets = .failure(.loading)
_ = self.budgetRepository.getBudgets()
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (status) in
switch status {
case .finished:
return
case .failure(let error):
self.budgets = .failure(error)
return
}
}, receiveValue: { (budgets) in
self.budgets = .success(budgets)
})
}
init(_ budgetRepository: BudgetRepository) {
self.budgetRepository = budgetRepository
}
private let budgetRepository: BudgetRepository
// Needed since the default implementation is currently broken
let objectWillChange = ObservableObjectPublisher()
}

View file

@ -0,0 +1,68 @@
//
// BudgetsView.swift
// Budget
//
// Created by Billy Brawner on 9/30/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
import SwiftUI
import Combine
struct BudgetsView: View {
@ObservedObject var budgetsDataStore: BudgetsDataStore
var body: some View {
NavigationView {
stateContent.navigationBarTitle("budgets")
}
}
var stateContent: AnyView {
switch budgetsDataStore.budgets {
case .success(let budgets):
return AnyView(List(budgets) { budget in
BudgetListItemView(budget)
})
case .failure(.loading):
return AnyView(VStack {
ActivityIndicator(isAnimating: .constant(true), style: .large)
})
default:
// TODO: Handle each network failure type
return AnyView(Text("budgets_load_failure"))
}
}
init(_ budgetsDataStore: BudgetsDataStore) {
self.budgetsDataStore = budgetsDataStore
self.budgetsDataStore.getBudgets()
}
}
struct BudgetListItemView: View {
var budget: Budget
var body: some View {
NavigationLink(
destination: CategoriesView()
.navigationBarTitle(budget.name)
) {
VStack(alignment: .leading) {
Text(verbatim: budget.name)
Text(verbatim: budget.description ?? "")
.foregroundColor(.gray)
}
}
}
init (_ budget: Budget) {
self.budget = budget
}
}
//struct BudgetsView_Previews: PreviewProvider {
// static var previews: some View {
// BudgetsView(budgets: [])
// }
//}

View file

@ -0,0 +1,21 @@
//
// CategoriesView.swift
// Budget
//
// Created by Billy Brawner on 9/30/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
import SwiftUI
struct CategoriesView: View {
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello World!"/*@END_MENU_TOKEN@*/)
}
}
struct CategoriesView_Previews: PreviewProvider {
static var previews: some View {
CategoriesView()
}
}

View file

@ -13,17 +13,28 @@ struct ContentView: View {
var body: some View {
Group {
if userData.status == .authenticated {
TabbedBudgetView(userData)
} else {
if showLogin() {
LoginView(userData)
} else {
TabbedBudgetView(userData, budgetRepository: budgetRepository)
}
}
}
func showLogin() -> Bool {
switch userData.currentUser {
case .failure:
return true
default:
return false
}
}
init (_ userData: UserDataStore) {
private let budgetRepository: BudgetRepository
init (_ userData: UserDataStore, budgetRepository: BudgetRepository) {
self.userData = userData
self.budgetRepository = budgetRepository
}
}

View file

@ -13,10 +13,11 @@ struct LoginView: View {
@State var username: String = ""
@State var password: String = ""
@ObservedObject var userData: UserDataStore
let showLoader: Bool
var body: some View {
LoadingView(
isShowing: .constant(userData.status == UserStatus.authenticating),
isShowing: .constant(showLoader),
loadingText: "loading_login"
) {
VStack {
@ -33,9 +34,13 @@ struct LoginView: View {
}
}
init (_ userData: UserDataStore) {
self.userData = userData
if case userData.currentUser = Result<User, UserStatus>.failure(UserStatus.authenticating) {
self.showLoader = true
} else {
self.showLoader = false
}
}
}

View file

@ -12,25 +12,60 @@ import Combine
class BudgetApiService {
let requestHelper: RequestHelper
init(requestHelper: RequestHelper) {
init(_ requestHelper: RequestHelper) {
self.requestHelper = requestHelper
}
// MARK: Budgets
func getBudgets(count: Int? = nil, page: Int? = nil) -> AnyPublisher<[Budget], NetworkError> {
var queries = [String: Array<String>]()
if count != nil {
queries["count"] = [String(count!)]
}
if (page != nil) {
queries["page"] = [String(page!)]
}
return requestHelper.get("/budgets", queries: queries)
}
func getBudget(_ id: Int) -> AnyPublisher<Budget, NetworkError> {
return requestHelper.get("/budgets/\(id)")
}
func getBudgetBalance(_ id: Int) -> AnyPublisher<Int, NetworkError> {
return requestHelper.get("/budgets/\(id)/balance")
}
func newBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> {
return requestHelper.post("/budgets/new", data: budget)
}
func updateBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> {
return requestHelper.put("/budgets/\(budget.id!)", data: budget)
}
// TODO: Figure out how to implement this
// func deleteBudget(_ id: Int) -> AnyPublisher<Void, NetworkError> {
// return requestHelper.delete("/budgets/\(id)")
// }
// MARK: Users
func login(username: String, password: String) -> AnyPublisher<User, NetworkError> {
requestHelper.credentials = (username, password)
return requestHelper.post(
endPoint: "/users/login",
"/users/login",
data: LoginRequest(username: username, password: password)
)
}
func getUser(id: Int) -> AnyPublisher<User, NetworkError> {
return requestHelper.get(endPoint: "/users/\(id)")
return requestHelper.get("/users/\(id)")
}
func searchUsers(query: String) -> AnyPublisher<[User], NetworkError> {
return requestHelper.get(
endPoint: "/users/search",
"/users/search",
queries: ["query": [query]]
)
}
@ -50,7 +85,7 @@ class RequestHelper {
}
func get<ResultType: Codable>(
endPoint: String,
_ endPoint: String,
queries: [String: Array<String>]? = nil
) -> AnyPublisher<ResultType, NetworkError> {
var combinedEndPoint = endPoint
@ -67,7 +102,7 @@ class RequestHelper {
}
func post<ResultType: Codable>(
endPoint: String,
_ endPoint: String,
data: Codable
) -> AnyPublisher<ResultType, NetworkError> {
return buildRequest(
@ -78,7 +113,7 @@ class RequestHelper {
}
func put<ResultType: Codable>(
endPoint: String,
_ endPoint: String,
data: ResultType
) -> AnyPublisher<ResultType, NetworkError> {
return buildRequest(
@ -88,7 +123,7 @@ class RequestHelper {
)
}
func delete<ResultType: Codable>(endPoint: String) -> AnyPublisher<ResultType, NetworkError> {
func delete<ResultType: Codable>(_ endPoint: String) -> AnyPublisher<ResultType, NetworkError> {
return buildRequest(endPoint: endPoint, method: "DELETE")
}
@ -136,6 +171,7 @@ class RequestHelper {
}
enum NetworkError: Error {
case loading
case unknown
case notFound
case unauthorized

View file

@ -13,7 +13,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
let userRepository: UserRepository
let budgetRepository: BudgetRepository
override init() {
// TODO: Dependency injection?
#if DEBUG
@ -23,8 +24,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
let baseUrl = "https://budget-api.intra.wbrawner.com"
#endif
let requestHelper = RequestHelper(baseUrl: baseUrl)
let apiService = BudgetApiService(requestHelper: requestHelper)
let apiService = BudgetApiService(requestHelper)
userRepository = UserRepository(apiService: apiService)
budgetRepository = BudgetRepository(apiService)
super.init()
}
@ -34,7 +36,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView(UserDataStore(self.userRepository))
let contentView = ContentView(
UserDataStore(self.userRepository),
budgetRepository: budgetRepository
)
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)

View file

@ -10,31 +10,28 @@ import SwiftUI
struct TabbedBudgetView: View {
@ObservedObject var userData: UserDataStore
let budgetRepository: BudgetRepository
var body: some View {
NavigationView {
TabView {
Text("Transactions here")
.tabItem {
Image(systemName: "dollarsign.circle")
Text("transactions")
}
Text("Budgets here")
.tabItem {
Image(systemName: "chart.pie.fill")
Text("budgets")
}
Text("Profile here")
.tabItem {
Image(systemName: "person")
Text("profile")
}
TabView {
Text("Transactions here").tabItem {
Image(systemName: "dollarsign.circle.fill")
Text("transactions")
}
BudgetsView(BudgetsDataStore(budgetRepository)).tabItem {
Image(systemName: "chart.pie.fill")
Text("budgets")
}
Text("Profile here").tabItem {
Image(systemName: "person.circle.fill")
Text("profile")
}
}
}
init (_ userData: UserDataStore) {
init (_ userData: UserDataStore, budgetRepository: BudgetRepository) {
self.userData = userData
self.budgetRepository = budgetRepository
}
}
//

View file

@ -8,7 +8,7 @@
import Foundation
struct User: Codable {
struct User: Codable, Equatable {
let id: Int?
let username: String
let email: String?

View file

@ -3,16 +3,16 @@ import Combine
class UserDataStore: ObservableObject {
// Note: You can combine these into one Result type
// Result<User, Status>
var currentUser: User? = nil
var status: UserStatus = .unauthenticated
var currentUser: Result<User, UserStatus> = .failure(.unauthenticated) {
didSet {
self.objectWillChange.send()
}
}
func login(username: String, password: String) {
// Changes the status and notifies any observers of the change
self.status = .authenticating
self.objectWillChange.send()
self.currentUser = .failure(.authenticating)
// Perform the login
_ = self.userRepository.login(username: username, password: password)
@ -24,27 +24,23 @@ class UserDataStore: ObservableObject {
// Do nothing it means the network request just ended
case .failure( _):
// Poulate your status with failed authenticating
self.status = .failedAuthentication
self.objectWillChange.send()
self.currentUser = .failure(.failedAuthentication)
}
}) { (user) in
self.currentUser = user
if user.id != nil {
self.status = .authenticated
}
self.objectWillChange.send()
self.currentUser = .success(user)
}
}
init(_ userRepository: UserRepository) {
self.userRepository = userRepository
}
let objectWillChange = ObservableObjectPublisher() // needed since the default implementation is currently broken
// Needed since the default implementation is currently broken
let objectWillChange = ObservableObjectPublisher()
private let userRepository: UserRepository
}
enum UserStatus {
enum UserStatus: Error, Equatable {
case unauthenticated
case authenticating
case failedAuthentication

View file

@ -19,3 +19,6 @@
// MARK: Budgets
"budgets" = "Budgets";
// MARK: Profile
"profile" = "Profile";

View file

@ -19,3 +19,6 @@
// MARK: Budgets
"budgets" = "Presupuestos";
// MARK: Profile
"profile" = "Perfil";