diff --git a/BetterBlue/Models/Account.swift b/BetterBlue/Models/Account.swift index 9981a25..22b64e9 100644 --- a/BetterBlue/Models/Account.swift +++ b/BetterBlue/Models/Account.swift @@ -702,37 +702,58 @@ extension BBAccount { } } -// MARK: - EV Trip Details +// MARK: - EV Trip Summary & Info extension BBAccount { - /// Fetches EV trip details for a vehicle. Returns nil if the API doesn't support this feature. + /// Fetches EV trip summary for a vehicle. Returns nil if the API doesn't support this feature. @MainActor - func fetchEVTripDetails(for bbVehicle: BBVehicle, modelContext: ModelContext) async throws -> [EVTripDetail]? { + func fetchEVTripSummary(for bbVehicle: BBVehicle, modelContext: ModelContext) async throws -> [EVTripSummary]? { guard let api, let authToken else { try await initialize(modelContext: modelContext) - return try await fetchEVTripDetails(for: bbVehicle, modelContext: modelContext) + return try await fetchEVTripSummary(for: bbVehicle, modelContext: modelContext) } let vehicle = bbVehicle.toVehicle() do { - return try await api.fetchEVTripDetails(for: vehicle, authToken: authToken) + return try await api.fetchEVTripSummary(for: vehicle, authToken: authToken) } catch let error as APIError where shouldReauthenticate(for: error) { try await handleAPIError(error, modelContext: modelContext) guard let api = self.api, let authToken = self.authToken else { throw APIError.failedRetryLogin() } - return try await api.fetchEVTripDetails(for: vehicle, authToken: authToken) + return try await api.fetchEVTripSummary(for: vehicle, authToken: authToken) } } - /// Returns true if the account's API supports EV trip details. - /// Delegates to the client (like `supportsMFA`) so new brand support in - /// BetterBlueKit lights up without app changes. False until the client + /// Fetches EV trip info for a specific date. Returns nil if the API doesn't support this feature. + @MainActor + func fetchEVTripInfo(for bbVehicle: BBVehicle, date: Date, modelContext: ModelContext) async throws -> [EVTripInfo]? { + guard let api, let authToken else { + try await initialize(modelContext: modelContext) + return try await fetchEVTripInfo(for: bbVehicle, date: date, modelContext: modelContext) + } + + let vehicle = bbVehicle.toVehicle() + + do { + return try await api.fetchEVTripInfo(for: vehicle, authToken: authToken, date: date) + } catch let error as APIError where shouldReauthenticate(for: error) { + try await handleAPIError(error, modelContext: modelContext) + guard let api = self.api, let authToken = self.authToken else { + throw APIError.failedRetryLogin() + } + return try await api.fetchEVTripInfo(for: vehicle, authToken: authToken, date: date) + } + } + + /// Returns the EV trip types supported by the account's API. + /// Delegates to the client so new brand support in BetterBlueKit + /// lights up without app changes. Empty until the client /// is initialized; the UI re-evaluates once startup init completes. @MainActor - var supportsEVTripDetails: Bool { - api?.supportsEVTripDetails() ?? false + var supportedEVTripTypes: [EVTripType] { + api?.supportedEVTripTypes() ?? [] } } diff --git a/BetterBlue/Utility/CachedAPIClient.swift b/BetterBlue/Utility/CachedAPIClient.swift index 1ecf61a..a7fa835 100644 --- a/BetterBlue/Utility/CachedAPIClient.swift +++ b/BetterBlue/Utility/CachedAPIClient.swift @@ -205,16 +205,21 @@ class CachedAPIClient: APIClientProtocol { try await task.value } - func fetchEVTripDetails(for vehicle: Vehicle, authToken: AuthToken) async throws -> [EVTripDetail]? { + func fetchEVTripSummary(for vehicle: Vehicle, authToken: AuthToken) async throws -> [EVTripSummary]? { // Trip details are not cached - forward directly to underlying client - BBLogger.debug(.api, "CachedAPIClient:Forwarding fetchEVTripDetails request for VIN: \(vehicle.vin)") - return try await underlyingClient.fetchEVTripDetails(for: vehicle, authToken: authToken) + BBLogger.debug(.api, "CachedAPIClient:Forwarding fetchEVTripSummary request for VIN: \(vehicle.vin)") + return try await underlyingClient.fetchEVTripSummary(for: vehicle, authToken: authToken) } - func supportsEVTripDetails() -> Bool { + func fetchEVTripInfo(for vehicle: Vehicle, authToken: AuthToken, date: Date) async throws -> [EVTripInfo]? { + BBLogger.debug(.api, "CachedAPIClient:Forwarding fetchEVTripInfo request for VIN: \(vehicle.vin)") + return try await underlyingClient.fetchEVTripInfo(for: vehicle, authToken: authToken, date: date) + } + + func supportedEVTripTypes() -> [EVTripType] { // Without this forward the protocol's default (false) would answer // for the wrapper and hide trip details for every account. - underlyingClient.supportsEVTripDetails() + underlyingClient.supportedEVTripTypes() } /// Invalidates the cached status for a specific vehicle diff --git a/BetterBlue/Views/Components/PersistentVehicleSheet.swift b/BetterBlue/Views/Components/PersistentVehicleSheet.swift index 855ed96..52c4db9 100644 --- a/BetterBlue/Views/Components/PersistentVehicleSheet.swift +++ b/BetterBlue/Views/Components/PersistentVehicleSheet.swift @@ -884,7 +884,7 @@ struct PersistentVehicleSheet: View { } if bbVehicle.fuelType.hasElectricCapability, - bbVehicle.account?.supportsEVTripDetails == true { + bbVehicle.account?.supportedEVTripTypes.contains(.summary) == true { Button { sheetPresentation.show(.tripDetails(vehicle: bbVehicle)) } label: { diff --git a/BetterBlue/Views/Components/TripDetailsView.swift b/BetterBlue/Views/Components/TripDetailsView.swift index 218af25..c075009 100644 --- a/BetterBlue/Views/Components/TripDetailsView.swift +++ b/BetterBlue/Views/Components/TripDetailsView.swift @@ -13,11 +13,16 @@ import SwiftData struct TripDetailsView: View { let bbVehicle: BBVehicle @Environment(\.modelContext) private var modelContext - @State private var trips: [EVTripDetail] = [] + @State private var trips: [EVTripSummary] = [] @State private var isLoading = true @State private var loadError: ActionError? @State private var appSettings = AppSettings.shared @State private var showEnergyBreakdown = false + /// Offset from current period. + /// EU (weekly): 0 = this week, -1 = last week. + /// Other (daily): 0 = today, -1 = yesterday. + @State private var periodOffset: Int = 0 + @State private var detailedTrips: [Date: [EVTripInfo]] = [:] var body: some View { PersistentModelGuard(model: bbVehicle) { @@ -37,6 +42,11 @@ struct TripDetailsView: View { .task { await loadTripDetails() } + .onChange(of: periodOffset) { _, _ in + Task { + await loadDetailedTripsForSelectedPeriod() + } + } } } @@ -84,6 +94,12 @@ struct TripDetailsView: View { private var tripListView: some View { List { + // Period navigator (week for EU daily summaries, day for others) + Section { + periodNavigatorView + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + // Energy usage chart section Section { energyUsageChartView @@ -102,8 +118,23 @@ struct TripDetailsView: View { // Trip list section Section { - ForEach(trips) { trip in - TripDetailRow(trip: trip, distanceUnit: appSettings.preferredDistanceUnit) + if tripsForSelectedPeriod.isEmpty { + Text(isDailySummaryData ? "No trips this week" : "No trips today") + .font(.subheadline) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } else { + ForEach(tripsForSelectedPeriod) { trip in + TripDetailRow( + trip: trip, + distanceUnit: appSettings.preferredDistanceUnit, + isDailySummary: isDailySummaryData, + bbVehicle: bbVehicle, + supportsTripInfo: supportsTripInfo, + detailedTrips: detailedTrips[trip.startDate] + ) + } } } header: { Text("Recent Trips") @@ -111,6 +142,48 @@ struct TripDetailsView: View { } } + private var periodNavigatorView: some View { + HStack { + Button { + withAnimation(.easeInOut(duration: 0.25)) { periodOffset -= 1 } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + .frame(width: 32, height: 32) + .background(Color(.secondarySystemBackground)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + + Spacer() + + VStack(spacing: 2) { + Text(periodLabel) + .font(.subheadline) + .fontWeight(.semibold) + Text(periodSubLabel) + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + + Button { + withAnimation(.easeInOut(duration: 0.25)) { periodOffset += 1 } + } label: { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(periodOffset < 0 ? .primary : .secondary) + .frame(width: 32, height: 32) + .background(Color(.secondarySystemBackground)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled(periodOffset >= 0) + } + } + private var energyUsageChartView: some View { Group { if showEnergyBreakdown { @@ -120,22 +193,35 @@ struct TripDetailsView: View { } } .animation(.easeInOut(duration: 0.3), value: showEnergyBreakdown) + .animation(.easeInOut(duration: 0.25), value: periodOffset) .padding(.horizontal) } /// Indexed trips for categorical x-axis (oldest first for left-to-right display) - private var indexedTrips: [(index: Int, trip: EVTripDetail)] { - Array(trips.reversed().enumerated().map { ($0.offset, $0.element) }) + private var indexedTrips: [(index: Int, trip: EVTripSummary)] { + Array(tripsForSelectedPeriod.reversed().enumerated().map { ($0.offset, $0.element) }) } - private func formatTripTime(_ date: Date) -> String { - date.formatted(.dateTime.hour().minute()) + private var isDailySummaryData: Bool { + guard !trips.isEmpty else { return false } + return trips.allSatisfy { + let comps = Calendar.current.dateComponents([.hour, .minute], from: $0.startDate) + return comps.hour == 0 && comps.minute == 0 + } + } + + private func formatTripLabel(_ date: Date) -> String { + if isDailySummaryData { + return date.formatted(.dateTime.month(.abbreviated).day()) + } else { + return date.formatted(.dateTime.hour().minute()) + } } private var totalEnergyChart: some View { Chart(indexedTrips, id: \.index) { item in BarMark( - x: .value("Trip", formatTripTime(item.trip.startDate)), + x: .value("Trip", formatTripLabel(item.trip.startDate)), y: .value("Energy", Double(item.trip.totalEnergyUsed) / 1000.0) ) .foregroundStyle(by: .value("Category", "Total")) @@ -166,6 +252,8 @@ struct TripDetailsView: View { } .chartYAxisLabel("kWh", position: .leading) .chartLegend(position: .bottom, spacing: 8) + .chartScrollableAxes(.horizontal) + .chartXVisibleDomain(length: 7) } private var stackedEnergyChart: some View { @@ -205,12 +293,14 @@ struct TripDetailsView: View { } .chartYAxisLabel("kWh", position: .leading) .chartLegend(position: .bottom, spacing: 8) + .chartScrollableAxes(.horizontal) + .chartXVisibleDomain(length: 7) } /// Data points for the stacked energy breakdown chart private var energyBreakdownData: [EnergyDataPoint] { indexedTrips.flatMap { item -> [EnergyDataPoint] in - let tripLabel = formatTripTime(item.trip.startDate) + let tripLabel = formatTripLabel(item.trip.startDate) var points: [EnergyDataPoint] = [ EnergyDataPoint( tripLabel: tripLabel, @@ -239,6 +329,70 @@ struct TripDetailsView: View { } } + // MARK: - Period helpers + + private var selectedWeekStart: Date { + let cal = Calendar.current + var comps = cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date()) + comps.weekday = 2 // Monday + let thisMonday = cal.date(from: comps) ?? Date() + return cal.date(byAdding: .weekOfYear, value: periodOffset, to: thisMonday) ?? Date() + } + + private var selectedWeekEnd: Date { + Calendar.current.date(byAdding: .day, value: 7, to: selectedWeekStart) ?? Date() + } + + private var selectedDayStart: Date { + let cal = Calendar.current + let today = cal.startOfDay(for: Date()) + return cal.date(byAdding: .day, value: periodOffset, to: today) ?? today + } + + private var selectedDayEnd: Date { + Calendar.current.date(byAdding: .day, value: 1, to: selectedDayStart) ?? Date() + } + + private var periodLabel: String { + let fmt = DateFormatter() + if isDailySummaryData { + fmt.dateFormat = "MMM d" + let end = Calendar.current.date(byAdding: .day, value: 6, to: selectedWeekStart) ?? selectedWeekEnd + return "\(fmt.string(from: selectedWeekStart)) – \(fmt.string(from: end))" + } else { + fmt.dateFormat = "EEE, MMM d" + return fmt.string(from: selectedDayStart) + } + } + + private var periodSubLabel: String { + if isDailySummaryData { + switch periodOffset { + case 0: return "This Week" + case -1: return "Last Week" + default: return "\(-periodOffset) weeks ago" + } + } else { + switch periodOffset { + case 0: return "Today" + case -1: return "Yesterday" + default: return "\(-periodOffset) days ago" + } + } + } + + private var tripsForSelectedPeriod: [EVTripSummary] { + if isDailySummaryData { + return trips.filter { $0.startDate >= selectedWeekStart && $0.startDate < selectedWeekEnd } + } else { + return trips.filter { $0.startDate >= selectedDayStart && $0.startDate < selectedDayEnd } + } + } + + private var supportsTripInfo: Bool { + bbVehicle.account?.supportedEVTripTypes.contains(.info) == true + } + private func loadTripDetails() async { isLoading = true loadError = nil @@ -253,8 +407,9 @@ struct TripDetailsView: View { } do { - if let fetchedTrips = try await account.fetchEVTripDetails(for: bbVehicle, modelContext: modelContext) { + if let fetchedTrips = try await account.fetchEVTripSummary(for: bbVehicle, modelContext: modelContext) { trips = fetchedTrips + await loadDetailedTripsForSelectedPeriod() } else { loadError = ActionError( action: "Load trip history", @@ -276,6 +431,28 @@ struct TripDetailsView: View { isLoading = false } + + private func loadDetailedTripsForSelectedPeriod() async { + guard supportsTripInfo, let account = bbVehicle.account else { return } + + let tripsToFetch = tripsForSelectedPeriod.filter { detailedTrips[$0.startDate] == nil } + guard !tripsToFetch.isEmpty else { return } + + for trip in tripsToFetch { + let date = trip.startDate + do { + let info = try await account.fetchEVTripInfo(for: bbVehicle, date: date, modelContext: modelContext) + if let info = info { + detailedTrips[date] = info.sorted(by: { $0.date > $1.date }) + } else { + detailedTrips[date] = [] + } + } catch { + BBLogger.error(.api, "TripDetailsView: Failed to fetch detailed trips for \(date): \(error)") + detailedTrips[date] = [] + } + } + } } // MARK: - Energy Data Point @@ -291,10 +468,46 @@ struct EnergyDataPoint: Identifiable { // MARK: - Trip Detail Row struct TripDetailRow: View { - let trip: EVTripDetail + let trip: EVTripSummary let distanceUnit: Distance.Units + let isDailySummary: Bool + var bbVehicle: BBVehicle? = nil + var supportsTripInfo: Bool = false + var detailedTrips: [EVTripInfo]? = nil @State private var isExpanded = false + private var effectiveAvgSpeed: Int { + if let detailedTrips = detailedTrips, !detailedTrips.isEmpty { + let validSpeeds = detailedTrips.filter { $0.avgSpeed > 0 } + if validSpeeds.isEmpty { return Int(trip.avgSpeed) } + let avg = validSpeeds.reduce(0.0) { $0 + $1.avgSpeed } / Double(validSpeeds.count) + return Int(distanceUnit.abbreviation == "mi" ? avg * 0.621371 : avg) + } + return Int(trip.avgSpeed) + } + + private var effectiveMaxSpeed: Int { + if let detailedTrips = detailedTrips, !detailedTrips.isEmpty { + let max = detailedTrips.map { $0.maxSpeed }.max() ?? 0 + return max > 0 ? Int(distanceUnit.abbreviation == "mi" ? max * 0.621371 : max) : Int(trip.maxSpeed) + } + return Int(trip.maxSpeed) + } + + private var speedUnitString: String { + distanceUnit.abbreviation == "mi" ? "mph" : "km/h" + } + + private var effectiveDurationString: String { + if let detailedTrips = detailedTrips, !detailedTrips.isEmpty { + let totalSeconds = detailedTrips.reduce(0 as Int64) { $0 + $1.driveTime.components.seconds } + if totalSeconds > 0 { + return Duration.seconds(totalSeconds).formatted(.units(allowed: [.hours, .minutes], width: .abbreviated)) + } + } + return trip.formattedDuration + } + private var formattedDistance: String { trip.distance.units.format(trip.distance.length, to: distanceUnit) } @@ -313,14 +526,25 @@ struct TripDetailRow: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - // Header row with date and distance (never animates) + // Touchable Header Area + VStack(alignment: .leading, spacing: 8) { + // Header row with date and distance (never animates) HStack { - Text( - trip.startDate, - format: .dateTime.weekday(.abbreviated).month(.abbreviated).day().hour().minute() - ) - .font(.subheadline) - .fontWeight(.medium) + if isDailySummary { + Text( + trip.startDate, + format: .dateTime.weekday(.abbreviated).month(.abbreviated).day() + ) + .font(.subheadline) + .fontWeight(.medium) + } else { + Text( + trip.startDate, + format: .dateTime.weekday(.abbreviated).month(.abbreviated).day().hour().minute() + ) + .font(.subheadline) + .fontWeight(.medium) + } Spacer() Text(formattedDistance) .font(.subheadline) @@ -330,7 +554,7 @@ struct TripDetailRow: View { // Summary row (never animates except chevron) HStack { - Text(trip.formattedDuration) + Text(effectiveDurationString) .font(.caption) .foregroundColor(.secondary) @@ -357,8 +581,15 @@ struct TripDetailRow: View { .animation(.easeInOut(duration: 0.2), value: isExpanded) } .animation(nil, value: isExpanded) + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } + } - // Expanded energy breakdown + // Expanded energy breakdown if isExpanded { VStack(alignment: .leading, spacing: 8) { if trip.batteryCareEnergy > 0 { @@ -421,26 +652,44 @@ struct TripDetailRow: View { // Speed info HStack { - Text("Avg: \(Int(trip.avgSpeed)) mph") + Text("Avg: \(effectiveAvgSpeed) \(speedUnitString)") .font(.caption2) .foregroundColor(.secondary) Text("•") .foregroundColor(.secondary) - Text("Max: \(Int(trip.maxSpeed)) mph") + Text("Max: \(effectiveMaxSpeed) \(speedUnitString)") .font(.caption2) .foregroundColor(.secondary) Spacer() } + + if supportsTripInfo { + Divider() + .padding(.vertical, 4) + + if let detailedTrips = detailedTrips { + if detailedTrips.isEmpty { + Text("No individual trips found.") + .font(.caption) + .foregroundColor(.secondary) + } else { + VStack(spacing: 6) { + ForEach(detailedTrips, id: \.self) { detail in + TripInfoPillView(trip: detail, distanceUnit: distanceUnit) + } + } + } + } else { + ProgressView("Loading trips...") + .font(.caption) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + } } } } .padding(.vertical, 4) - .contentShape(Rectangle()) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { - isExpanded.toggle() - } - } } } @@ -473,11 +722,57 @@ struct EnergyBreakdownPill: View { } } -// MARK: - Previews +// MARK: - Trip Info Pill + +struct TripInfoPillView: View { + let trip: EVTripInfo + let distanceUnit: Distance.Units + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(trip.date, format: .dateTime.hour().minute()) + .font(.caption2) + .foregroundColor(.secondary) + Text(distanceUnit.format(trip.distance.length, to: distanceUnit)) + .font(.caption) + .fontWeight(.medium) + } + .frame(width: 65, alignment: .leading) + + VStack(alignment: .leading, spacing: 2) { + Text("Drive") + .font(.caption2) + .foregroundColor(.secondary) + Text(trip.driveTime.formatted(.units(allowed: [.hours, .minutes], width: .abbreviated))) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.blue) + } + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 2) { + Text("Idle") + .font(.caption2) + .foregroundColor(.secondary) + Text(trip.idleTime.formatted(.units(allowed: [.hours, .minutes], width: .abbreviated))) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.orange) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(8) + .background(Color(.tertiarySystemFill)) + .cornerRadius(8) + } +} +// MARK: - Previews +/* #Preview("Trip Details - With Data") { NavigationView { - TripDetailsPreviewWrapper(trips: EVTripDetail.sampleTrips) + TripDetailsPreviewWrapper(trips: EVTripSummary.sampleTrips) } } @@ -503,7 +798,8 @@ struct EnergyBreakdownPill: View { List { TripDetailRow( trip: .sample, - distanceUnit: .miles + distanceUnit: .miles, + isDailySummary: false ) } } @@ -511,7 +807,7 @@ struct EnergyBreakdownPill: View { // MARK: - Preview Helpers private struct TripDetailsPreviewWrapper: View { - let trips: [EVTripDetail]? + let trips: [EVTripSummary]? var isLoading: Bool = false var errorMessage: String? @@ -527,11 +823,12 @@ private struct TripDetailsPreviewWrapper: View { } private struct TripDetailsPreviewContent: View { - let trips: [EVTripDetail] + let trips: [EVTripSummary] let isLoading: Bool let errorMessage: String? @State private var appSettings = AppSettings.shared @State private var showEnergyBreakdown = false + @State private var periodOffset: Int = 0 var body: some View { Group { @@ -571,6 +868,49 @@ private struct TripDetailsPreviewContent: View { .padding() } else { List { + Section { + HStack { + Button { + withAnimation(.easeInOut(duration: 0.25)) { periodOffset -= 1 } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + .frame(width: 32, height: 32) + .background(Color(.secondarySystemBackground)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + + Spacer() + + VStack(spacing: 2) { + Text(periodLabel) + .font(.subheadline) + .fontWeight(.semibold) + Text(periodSubLabel) + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + + Button { + withAnimation(.easeInOut(duration: 0.25)) { periodOffset += 1 } + } label: { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(periodOffset < 0 ? .primary : .secondary) + .frame(width: 32, height: 32) + .background(Color(.secondarySystemBackground)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled(periodOffset >= 0) + } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + Section { Group { if showEnergyBreakdown { @@ -580,6 +920,7 @@ private struct TripDetailsPreviewContent: View { } } .animation(.easeInOut(duration: 0.3), value: showEnergyBreakdown) + .animation(.easeInOut(duration: 0.25), value: periodOffset) .padding(.horizontal) .frame(height: 200) .listRowInsets(EdgeInsets(top: 12, leading: 0, bottom: 12, trailing: 0)) @@ -595,23 +936,112 @@ private struct TripDetailsPreviewContent: View { } Section { - ForEach(trips) { trip in - TripDetailRow(trip: trip, distanceUnit: appSettings.preferredDistanceUnit) + if tripsForSelectedPeriod.isEmpty { + Text(isDailySummaryData ? "No trips this week" : "No trips today") + .font(.subheadline) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } else { + ForEach(tripsForSelectedPeriod) { trip in + TripDetailRow(trip: trip, distanceUnit: appSettings.preferredDistanceUnit, isDailySummary: isDailySummaryData) + } } } header: { Text("Recent Trips") } } + .gesture( + DragGesture() + .onEnded { value in + let threshold: CGFloat = 50 + if value.translation.width < -threshold, periodOffset < 0 { + withAnimation(.easeInOut(duration: 0.25)) { periodOffset += 1 } + } else if value.translation.width > threshold { + withAnimation(.easeInOut(duration: 0.25)) { periodOffset -= 1 } + } + } + ) } } } - private var indexedTrips: [(index: Int, trip: EVTripDetail)] { - Array(trips.reversed().enumerated().map { ($0.offset, $0.element) }) + private var isDailySummaryData: Bool { + guard !trips.isEmpty else { return false } + return trips.allSatisfy { + let comps = Calendar.current.dateComponents([.hour, .minute], from: $0.startDate) + return comps.hour == 0 && comps.minute == 0 + } + } + + private var selectedWeekStart: Date { + let cal = Calendar.current + var comps = cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date()) + comps.weekday = 2 + let thisMonday = cal.date(from: comps) ?? Date() + return cal.date(byAdding: .weekOfYear, value: periodOffset, to: thisMonday) ?? Date() + } + + private var selectedWeekEnd: Date { + Calendar.current.date(byAdding: .day, value: 7, to: selectedWeekStart) ?? Date() + } + + private var selectedDayStart: Date { + let cal = Calendar.current + let today = cal.startOfDay(for: Date()) + return cal.date(byAdding: .day, value: periodOffset, to: today) ?? today + } + + private var selectedDayEnd: Date { + Calendar.current.date(byAdding: .day, value: 1, to: selectedDayStart) ?? Date() + } + + private var periodLabel: String { + let fmt = DateFormatter() + if isDailySummaryData { + fmt.dateFormat = "MMM d" + let end = Calendar.current.date(byAdding: .day, value: 6, to: selectedWeekStart) ?? selectedWeekEnd + return "\(fmt.string(from: selectedWeekStart)) – \(fmt.string(from: end))" + } else { + fmt.dateFormat = "EEE, MMM d" + return fmt.string(from: selectedDayStart) + } + } + + private var periodSubLabel: String { + if isDailySummaryData { + switch periodOffset { + case 0: return "This Week" + case -1: return "Last Week" + default: return "\(-periodOffset) weeks ago" + } + } else { + switch periodOffset { + case 0: return "Today" + case -1: return "Yesterday" + default: return "\(-periodOffset) days ago" + } + } + } + + private var tripsForSelectedPeriod: [EVTripSummary] { + if isDailySummaryData { + return trips.filter { $0.startDate >= selectedWeekStart && $0.startDate < selectedWeekEnd } + } else { + return trips.filter { $0.startDate >= selectedDayStart && $0.startDate < selectedDayEnd } + } + } + + private var indexedTrips: [(index: Int, trip: EVTripSummary)] { + Array(tripsForSelectedPeriod.reversed().enumerated().map { ($0.offset, $0.element) }) } private func formatTripTime(_ date: Date) -> String { - date.formatted(.dateTime.hour().minute()) + if isDailySummaryData { + return date.formatted(.dateTime.month(.abbreviated).day()) + } else { + return date.formatted(.dateTime.hour().minute()) + } } private var totalEnergyChart: some View { @@ -648,6 +1078,8 @@ private struct TripDetailsPreviewContent: View { } .chartYAxisLabel("kWh", position: .leading) .chartLegend(position: .bottom, spacing: 8) + .chartScrollableAxes(.horizontal) + .chartXVisibleDomain(length: 7) } private var stackedEnergyChart: some View { @@ -687,6 +1119,8 @@ private struct TripDetailsPreviewContent: View { } .chartYAxisLabel("kWh", position: .leading) .chartLegend(position: .bottom, spacing: 8) + .chartScrollableAxes(.horizontal) + .chartXVisibleDomain(length: 7) } private var energyBreakdownData: [EnergyDataPoint] { @@ -723,9 +1157,9 @@ private struct TripDetailsPreviewContent: View { // MARK: - Sample Data -extension EVTripDetail { - static var sample: EVTripDetail { - EVTripDetail( +extension EVTripSummary { + static var sample: EVTripSummary { + EVTripSummary( distance: Distance(length: 7, units: .miles), odometer: Distance(length: 14214.1, units: .miles), accessoriesEnergy: 220, @@ -735,15 +1169,15 @@ extension EVTripDetail { drivetrainEnergy: 1635, batteryCareEnergy: 0, startDate: Date().addingTimeInterval(-3600), - durationSeconds: 1268, + duration: .seconds(1268), avgSpeed: 27.0, maxSpeed: 41.0 ) } - static var sampleTrips: [EVTripDetail] { + static var sampleTrips: [EVTripSummary] { [ - EVTripDetail( + EVTripSummary( distance: Distance(length: 7, units: .miles), odometer: Distance(length: 14214.1, units: .miles), accessoriesEnergy: 220, @@ -753,11 +1187,11 @@ extension EVTripDetail { drivetrainEnergy: 1635, batteryCareEnergy: 0, startDate: Date().addingTimeInterval(-3600), - durationSeconds: 1268, + duration: .seconds(1268), avgSpeed: 27.0, maxSpeed: 41.0 ), - EVTripDetail( + EVTripSummary( distance: Distance(length: 4, units: .miles), odometer: Distance(length: 14206.1, units: .miles), accessoriesEnergy: 160, @@ -767,11 +1201,11 @@ extension EVTripDetail { drivetrainEnergy: 1409, batteryCareEnergy: 200, startDate: Date().addingTimeInterval(-7200), - durationSeconds: 932, + duration: .seconds(932), avgSpeed: 24.0, maxSpeed: 42.0 ), - EVTripDetail( + EVTripSummary( distance: Distance(length: 4, units: .miles), odometer: Distance(length: 14200.9, units: .miles), accessoriesEnergy: 70, @@ -781,11 +1215,11 @@ extension EVTripDetail { drivetrainEnergy: 1177, batteryCareEnergy: 300, startDate: Date().addingTimeInterval(-10800), - durationSeconds: 526, + duration: .seconds(526), avgSpeed: 34.0, maxSpeed: 59.0 ), - EVTripDetail( + EVTripSummary( distance: Distance(length: 6, units: .miles), odometer: Distance(length: 14196.5, units: .miles), accessoriesEnergy: 90, @@ -795,10 +1229,11 @@ extension EVTripDetail { drivetrainEnergy: 1524, batteryCareEnergy: 200, startDate: Date().addingTimeInterval(-14400), - durationSeconds: 752, + duration: .seconds(752), avgSpeed: 32.0, maxSpeed: 51.0 ) ] } } +*/ diff --git a/BetterBlue/Views/Components/VehicleTitleView.swift b/BetterBlue/Views/Components/VehicleTitleView.swift index c9e6e82..49d8f86 100644 --- a/BetterBlue/Views/Components/VehicleTitleView.swift +++ b/BetterBlue/Views/Components/VehicleTitleView.swift @@ -540,7 +540,7 @@ struct VehicleTitleView: View { } } - if bbVehicle.fuelType.hasElectricCapability && vehicleAccount?.supportsEVTripDetails == true { + if bbVehicle.fuelType.hasElectricCapability && vehicleAccount?.supportedEVTripTypes.contains(.summary) == true { Button { showingTripDetails = true } label: {