From 86f2ee2197539e58c29c343be7182ed6c45e78f3 Mon Sep 17 00:00:00 2001 From: lukasskolek <32292886+lukasskolek@users.noreply.github.com> Date: Sat, 11 Jan 2025 00:38:08 +0100 Subject: [PATCH] The line chart for the odometer is ready. (#374) * The line chart for the odometer is ready. * PR suggestion --------- Co-authored-by: Mikaela Caron --- .../Shared/Localizable.xcstrings | 3 + .../Shared/Odometer/Views/OdometerView.swift | 157 ++++++++++++++---- 2 files changed, 127 insertions(+), 33 deletions(-) diff --git a/Basic-Car-Maintenance/Shared/Localizable.xcstrings b/Basic-Car-Maintenance/Shared/Localizable.xcstrings index d36f1b54..f5fc0d75 100644 --- a/Basic-Car-Maintenance/Shared/Localizable.xcstrings +++ b/Basic-Car-Maintenance/Shared/Localizable.xcstrings @@ -5241,6 +5241,9 @@ } } } + }, + "Time Range" : { + }, "Title" : { "comment" : "Maintenance event title text field header", diff --git a/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerView.swift b/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerView.swift index 68096fde..220afaab 100644 --- a/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerView.swift +++ b/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerView.swift @@ -7,12 +7,14 @@ // import SwiftUI +import Charts struct OdometerView: View { @Environment(ActionService.self) var actionService @State private var viewModel: OdometerViewModel - + @State private var selectedTimeRange: TimeRange = .all + init(userUID: String?) { self.init(viewModel: OdometerViewModel(userUID: userUID)) } @@ -20,35 +22,70 @@ struct OdometerView: View { fileprivate init(viewModel: OdometerViewModel) { self.viewModel = viewModel } - + var body: some View { NavigationStack { - List { - ForEach(viewModel.readings) { reading in - let vehicleName = viewModel.vehicles.first { $0.id == reading.vehicleID }?.name - OdometerRowView(reading: reading, vehicleName: vehicleName) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - Task { - await viewModel.deleteReading(reading) + VStack { + Picker("Time Range", selection: $selectedTimeRange) { + ForEach(TimeRange.allCases) { timeRange in + Text(timeRange.rawValue).tag(timeRange) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + + if !viewModel.readings.isEmpty { + GroupBox { + Chart { + ForEach(viewModel.vehicles) { vehicle in + let vehicleReadings = filteredReadings(for: vehicle) + + if !vehicleReadings.isEmpty { + ForEach(vehicleReadings) { reading in + LineMark( + x: .value("Date", reading.date, unit: .day), + y: .value("Odometer", reading.distance) + ) + } + .foregroundStyle(by: .value("Vehicle", vehicle.name)) + .symbol(by: .value("Vehicle", vehicle.name)) + .interpolationMethod(.monotone) } - } label: { - Image(systemName: SFSymbol.trash) } - - Button { - viewModel.selectedReading = reading - viewModel.isShowingEditReadingView = true - } label: { - Label { - Text("Edit") - } icon: { - Image(systemName: SFSymbol.pencil) + } + .frame(height: 200) + } + .padding(.horizontal) + .listRowSeparator(.hidden) + } + + List { + ForEach(viewModel.readings) { reading in + let vehicleName = viewModel.vehicles.first { $0.id == reading.vehicleID }?.name + OdometerRowView(reading: reading, vehicleName: vehicleName) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + Task { + await viewModel.deleteReading(reading) + } + } label: { + Image(systemName: SFSymbol.trash) + } + + Button { + viewModel.selectedReading = reading + viewModel.isShowingEditReadingView = true + } label: { + Label { + Text("Edit") + } icon: { + Image(systemName: SFSymbol.pencil) + } } } - } + } + .listStyle(.inset) } - .listStyle(.inset) } .overlay { if viewModel.readings.isEmpty { @@ -86,11 +123,38 @@ struct OdometerView: View { } } } - } .analyticsView("\(Self.self)") } + /// Filter the readins based on the selected time range, and if there are no readings in the last 30 days, just show the last reading. + /// - Parameter vehicle: The vehicle for these readings. + /// - Returns: The `[OdometerReading]`s for this vehicle in the time range. + private func filteredReadings(for vehicle: Vehicle) -> [OdometerReading] { + let vehicleReadings = viewModel.readings.filter { $0.vehicleID == vehicle.id } + + switch selectedTimeRange { + case .all: + return vehicleReadings + case .last30Days: + guard let lastReadingDate = vehicleReadings.map({ $0.date }).max() else { + return [] + } + let thirtyDaysAgo = Calendar.current.date(byAdding: .day, value: -30, to: Date()) ?? Date() + + // If the last reading is older than 30 days, include only the last reading + if lastReadingDate < thirtyDaysAgo { + if let lastReading = vehicleReadings.max(by: { $0.date < $1.date }) { + return [lastReading] + } else { + return [] + } + } else { + return vehicleReadings.filter { $0.date >= thirtyDaysAgo } + } + } + } + private func makeAddOdometerView() -> some View { AddOdometerReadingView(vehicles: viewModel.vehicles) { reading in do { @@ -112,6 +176,14 @@ struct OdometerView: View { } } +/// The time range options in the picker for the graph. +enum TimeRange: String, CaseIterable, Identifiable { + case all = "All readings" + case last30Days = "Latest readings" + + var id: String { self.rawValue } +} + #Preview { let viewModel = OdometerViewModel(userUID: nil) let firstCar = createVehicle(id: "id1", name: "My 1st car") @@ -119,18 +191,37 @@ struct OdometerView: View { viewModel.vehicles.append(contentsOf: [firstCar, secondCar]) - let firstReading = createReading(vehicleID: secondCar.id!, + let firstReading = createReading(vehicleID: firstCar.id!, date: "2024/10/18", - distance: 20) + distance: 35) let secondReading = createReading(vehicleID: firstCar.id!, - date: "2024/10/15", - distance: 1000) - + date: "2024/10/19", + distance: 564) let thirdReading = createReading(vehicleID: firstCar.id!, + date: "2024/11/23", + distance: 1000) + + let fourthReading = createReading(vehicleID: firstCar.id!, + date: "2024/11/30", + distance: 1024) + let fifthReading = createReading(vehicleID: secondCar.id!, + date: "2024/10/1", + distance: 1000) + + let sixthReading = createReading(vehicleID: secondCar.id!, date: "2024/10/13", - distance: 10) - viewModel.readings.append(contentsOf: [firstReading, secondReading, thirdReading]) - + distance: 1144) + let seventhReading = createReading(vehicleID: secondCar.id!, + date: "2024/10/15", + distance: 1412) + + let eighthReading = createReading(vehicleID: secondCar.id!, + date: "2024/11/13", + distance: 1542) + + // swiftlint:disable:next line_length + viewModel.readings.append(contentsOf: [firstReading, secondReading, thirdReading, fourthReading, fifthReading, sixthReading, seventhReading, eighthReading]) + return OdometerView(viewModel: viewModel) .environment(ActionService.shared) @@ -145,7 +236,7 @@ struct OdometerView: View { vin: nil, licensePlateNumber: nil) } - + func createReading(vehicleID: String, date: String, distance: Int) -> OdometerReading { let formatter = DateFormatter() formatter.dateFormat = "yyyy/MM/dd"