From 763862c8ee4a5de6e7004279e35d5a2a54c74f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Malte=20B=C3=BCnz?= Date: Tue, 1 Oct 2024 13:20:38 +0200 Subject: [PATCH] International event times (#511) * add extended query item * add city property and use timeZone for event time * update xcode version * add local time tests * remove helper * open sheet on banner tap * test iphone16 * update test * set ridetime in list item view * add test for didTapNextEventBanner --- .github/workflows/tests.yml | 2 +- .../Sources/AppFeature/AppFeatureCore.swift | 12 ++- .../Sources/AppFeature/AppView.swift | 94 ++++++++++--------- .../Sources/Helpers/Date+Additions.swift | 5 - .../Sources/Helpers/Timezone+Extras.swift | 1 + .../NextRideFeature/NextRidesRequest.swift | 4 +- .../SharedModels/NextRideFeature/Ride.swift | 51 +++++++++- .../AppFeatureTests/AppFeatureCoreTests.swift | 20 +++- .../UserTrackingButtonSnapshotTests.swift | 10 +- .../NextRideFeatureTests/RideTests.swift | 90 ------------------ .../NextRideFeatureTests/RideTimeTests.swift | 63 +++++++++++++ fastlane/Fastfile | 2 +- 12 files changed, 200 insertions(+), 154 deletions(-) delete mode 100644 CriticalMapsKit/Tests/NextRideFeatureTests/RideTests.swift create mode 100644 CriticalMapsKit/Tests/NextRideFeatureTests/RideTimeTests.swift diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d88ef448..9183a87b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,7 +26,7 @@ jobs: - name: Setup Xcode version uses: maxim-lobanov/setup-xcode@v1.6.0 with: - xcode-version: 15.4 + xcode-version: 16.0 - name: Run UnitTests run: fastlane test diff --git a/CriticalMapsKit/Sources/AppFeature/AppFeatureCore.swift b/CriticalMapsKit/Sources/AppFeature/AppFeatureCore.swift index c4de716e..f7ab60ef 100644 --- a/CriticalMapsKit/Sources/AppFeature/AppFeatureCore.swift +++ b/CriticalMapsKit/Sources/AppFeature/AppFeatureCore.swift @@ -143,6 +143,7 @@ public struct AppFeature { case requestTimer(RequestTimer.Action) case settings(SettingsFeature.Action) case social(SocialFeature.Action) + case didTapNextEventBanner public enum Alert: Equatable, Sendable { case observationMode(enabled: Bool) @@ -322,10 +323,9 @@ public struct AppFeature { case let .map(mapFeatureAction): switch mapFeatureAction { - case .focusRideEvent, - .focusNextRide: + case .focusRideEvent, .focusNextRide: if state.bottomSheetPosition != .hidden { - return .send(.set(\.$bottomSheetPosition, .relative(0.4))) + return .send(.set(\.$bottomSheetPosition, .relative(0.3))) } else { return .none } @@ -491,6 +491,12 @@ public struct AppFeature { default: return .none } + + case .didTapNextEventBanner: + return .merge( + .send(.map(.focusNextRide(state.nextRideState.nextRide?.coordinate))), + .send(.set(\.$bottomSheetPosition, .relative(0.3))) + ) case .binding: return .none diff --git a/CriticalMapsKit/Sources/AppFeature/AppView.swift b/CriticalMapsKit/Sources/AppFeature/AppView.swift index 1559bb8f..d1c7bdcc 100644 --- a/CriticalMapsKit/Sources/AppFeature/AppView.swift +++ b/CriticalMapsKit/Sources/AppFeature/AppView.swift @@ -51,12 +51,6 @@ public struct AppView: View { VStack(alignment: .leading) { if shouldShowNextRideBanner { nextRideBanner() - .contextMenu { - Button( - action: { viewStore.send(.set(\.$bottomSheetPosition, .relative(0.4))) }, - label: { Label(contextMenuTitle, systemImage: "list.bullet") } - ) - } } if viewStore.settingsState.infoViewEnabled { @@ -98,7 +92,7 @@ public struct AppView: View { .bottomSheet( bottomSheetPosition: viewStore.$bottomSheetPosition, switchablePositions: [ - .relative(0.4), + .relative(0.3), .relativeTop(0.975) ], title: "Events", @@ -181,45 +175,14 @@ public struct AppView: View { func bottomSheetContentView() -> some View { VStack { List(viewStore.nextRideState.rideEvents, id: \.id) { ride in - HStack(alignment: .center, spacing: .grid(2)) { - Image(uiImage: Asset.cm.image) - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: .grid(1)) { - Text(ride.title) - .multilineTextAlignment(.leading) - .font(Font.body.weight(.semibold)) - .foregroundColor(Color(.textPrimary)) - .padding(.bottom, .grid(1)) - - VStack(alignment: .leading, spacing: 2) { - Label(ride.dateTime.humanReadableDate, systemImage: "calendar") - .multilineTextAlignment(.leading) - .font(.bodyTwo) - .foregroundColor(Color(.textSecondary)) - - Label(ride.dateTime.humanReadableTime, systemImage: "clock") - .multilineTextAlignment(.leading) - .font(.bodyTwo) - .foregroundColor(Color(.textSecondary)) - - if let location = ride.location { - Label(location, systemImage: "location.fill") - .multilineTextAlignment(.leading) - .font(.bodyTwo) - .foregroundColor(Color(.textSecondary)) - } - } + RideEventView(ride: ride) + .contentShape(Rectangle()) + .padding(.vertical, .grid(1)) + .accessibilityElement(children: .combine) + .onTapGesture { + viewStore.send(.onRideSelectedFromBottomSheet(ride)) } - Spacer() - } - .contentShape(Rectangle()) - .padding(.vertical, .grid(1)) - .accessibilityElement(children: .combine) - .onTapGesture { - viewStore.send(.onRideSelectedFromBottomSheet(ride)) - } - .listRowBackground(Color.clear) + .listRowBackground(Color.clear) } .listStyle(.plain) } @@ -262,7 +225,7 @@ public struct AppView: View { }, action: { $0 } ), - action: { viewStore.send(.map(.focusNextRide(viewStore.nextRideState.nextRide?.coordinate))) }, + action: { viewStore.send(.didTapNextEventBanner) }, content: { VStack(alignment: .leading, spacing: .grid(1)) { Text(viewStore.state.nextRideState.nextRide?.title ?? "") @@ -303,3 +266,42 @@ struct NumericContentTransition: ViewModifier { } } } + +struct RideEventView: View { + let ride: Ride + + var body: some View { + HStack(alignment: .center, spacing: .grid(2)) { + Image(uiImage: Asset.cm.image) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: .grid(1)) { + Text(ride.title) + .multilineTextAlignment(.leading) + .font(Font.body.weight(.semibold)) + .foregroundColor(Color(.textPrimary)) + .padding(.bottom, .grid(1)) + + VStack(alignment: .leading, spacing: 2) { + Label(ride.dateTime.humanReadableDate, systemImage: "calendar") + .multilineTextAlignment(.leading) + .font(.bodyTwo) + .foregroundColor(Color(.textSecondary)) + + Label(ride.rideTime, systemImage: "clock") + .multilineTextAlignment(.leading) + .font(.bodyTwo) + .foregroundColor(Color(.textSecondary)) + + if let location = ride.location { + Label(location, systemImage: "location.fill") + .multilineTextAlignment(.leading) + .font(.bodyTwo) + .foregroundColor(Color(.textSecondary)) + } + } + } + Spacer() + } + } +} diff --git a/CriticalMapsKit/Sources/Helpers/Date+Additions.swift b/CriticalMapsKit/Sources/Helpers/Date+Additions.swift index 92a9b7f8..1964eb18 100644 --- a/CriticalMapsKit/Sources/Helpers/Date+Additions.swift +++ b/CriticalMapsKit/Sources/Helpers/Date+Additions.swift @@ -14,11 +14,6 @@ public extension Date { return component } - /// - Returns: Formatted time without date components. - var humanReadableTime: String { - self.formatted(Date.FormatStyle.localeAwareShortTime) - } - /// - Returns: Formatted date without time components. var humanReadableDate: String { self.formatted(Date.FormatStyle.localeAwareShortDate) diff --git a/CriticalMapsKit/Sources/Helpers/Timezone+Extras.swift b/CriticalMapsKit/Sources/Helpers/Timezone+Extras.swift index cb06108a..5fbaa0fd 100644 --- a/CriticalMapsKit/Sources/Helpers/Timezone+Extras.swift +++ b/CriticalMapsKit/Sources/Helpers/Timezone+Extras.swift @@ -7,6 +7,7 @@ public extension TimeZone { static let spain = TimeZone(identifier: "Europe/Madrid")! static let france = TimeZone(identifier: "Europe/Paris")! static let greece = TimeZone(identifier: "Europe/Athens")! + static let london = TimeZone(identifier: "Europe/London")! // America static let ecuador = TimeZone(identifier: "America/Guayaquil")! diff --git a/CriticalMapsKit/Sources/NextRideFeature/NextRidesRequest.swift b/CriticalMapsKit/Sources/NextRideFeature/NextRidesRequest.swift index 27e5537e..6a3e1394 100644 --- a/CriticalMapsKit/Sources/NextRideFeature/NextRidesRequest.swift +++ b/CriticalMapsKit/Sources/NextRideFeature/NextRidesRequest.swift @@ -17,7 +17,8 @@ public extension Request { URLQueryItem(name: NextRideQueryKeys.centerLatitude, value: String(coordinate.latitude)), URLQueryItem(name: NextRideQueryKeys.radius, value: String(radius)), URLQueryItem(name: NextRideQueryKeys.year, value: String(Date.getCurrent(\.year, date))), - URLQueryItem(name: NextRideQueryKeys.month, value: String(month)) + URLQueryItem(name: NextRideQueryKeys.month, value: String(month)), + URLQueryItem(name: NextRideQueryKeys.extended, value: "true"), ] ) } @@ -31,4 +32,5 @@ enum NextRideQueryKeys { static let radius = "radius" static let year = "year" static let month = "month" + static let extended = "extended" } diff --git a/CriticalMapsKit/Sources/SharedModels/NextRideFeature/Ride.swift b/CriticalMapsKit/Sources/SharedModels/NextRideFeature/Ride.swift index c9cd8230..4637eb03 100644 --- a/CriticalMapsKit/Sources/SharedModels/NextRideFeature/Ride.swift +++ b/CriticalMapsKit/Sources/SharedModels/NextRideFeature/Ride.swift @@ -1,3 +1,4 @@ +import ComposableArchitecture import CoreLocation import Foundation import Helpers @@ -5,6 +6,7 @@ import MapKit public struct Ride: Hashable, Codable, Identifiable { public let id: Int + public var city: City? public let slug: String? public let title: String public let description: String? @@ -22,6 +24,7 @@ public struct Ride: Hashable, Codable, Identifiable { public init( id: Int, + city: City? = nil, slug: String? = nil, title: String, description: String? = nil, @@ -38,6 +41,7 @@ public struct Ride: Hashable, Codable, Identifiable { rideType: Ride.RideType? = nil ) { self.id = id + self.city = city self.slug = slug self.title = title self.description = description @@ -55,6 +59,25 @@ public struct Ride: Hashable, Codable, Identifiable { } } +extension Ride { + public struct City: Codable, Hashable { + let id: Int + let name: String + let timezone: String + + public init( + id: Int, + name: String, + timezone: String + ) { + self.id = id + self.name = name + self.timezone = timezone + } + } + +} + public extension Ride { var coordinate: Coordinate? { guard let lat = latitude, let lng = longitude else { @@ -72,7 +95,18 @@ public extension Ride { } var rideDateAndTime: String { - "\(dateTime.humanReadableDate) - \(dateTime.humanReadableTime)" + "\(dateTime.humanReadableDate) - \(rideTime)" + } + + var rideTime: String { + if + let cityTimeZone = city?.timezone, + let timeZone = TimeZone(identifier: cityTimeZone) + { + return dateTime.formatted(Date.FormatStyle.shortTimeWithEventTimeZone(timeZone)) + } else { + return dateTime.formatted(Date.FormatStyle.localeAwareShortTime) + } } var shareMessage: String { @@ -82,6 +116,8 @@ public extension Ride { return """ \(titleAndTime) \(location) + + \(description ?? "") """ } } @@ -121,3 +157,16 @@ public extension Ride { } } } + +private extension Date.FormatStyle { + static func shortTimeWithEventTimeZone(_ timezone: TimeZone) -> Self { + @Dependency(\.locale) var locale + + return Self( + date: .omitted, + time: .shortened, + locale: locale, + timeZone: timezone + ) + } +} diff --git a/CriticalMapsKit/Tests/AppFeatureTests/AppFeatureCoreTests.swift b/CriticalMapsKit/Tests/AppFeatureTests/AppFeatureCoreTests.swift index 00dbbb94..48e6e8d9 100644 --- a/CriticalMapsKit/Tests/AppFeatureTests/AppFeatureCoreTests.swift +++ b/CriticalMapsKit/Tests/AppFeatureTests/AppFeatureCoreTests.swift @@ -297,7 +297,7 @@ final class AppFeatureTests: XCTestCase { await store.send(.map(.focusRideEvent(coordinate))) { $0.mapFeatureState.eventCenter = CoordinateRegion(center: coordinate.asCLLocationCoordinate) } - await store.receive(.binding(.set(\.$bottomSheetPosition, .relative(0.4)))) + await store.receive(.binding(.set(\.$bottomSheetPosition, .relative(0.3)))) await testClock.advance(by: .seconds(1)) await store.receive(.map(.resetRideEventCenter)) { $0.mapFeatureState.eventCenter = nil @@ -506,6 +506,24 @@ final class AppFeatureTests: XCTestCase { let didStopLocationObservationValue = await didStopLocationUpdating.value XCTAssertTrue(didStopLocationObservationValue) } + + func test_didTapNextEventBanner() async { + let store = await TestStore( + initialState: AppFeature.State(nextRideState: NextRideFeature.State(nextRide: Ride.mock1)), + reducer: { AppFeature() }, + withDependencies: { + $0.continuousClock = TestClock() + } + ) + store.exhaustivity = .off + + // act + await store.send(.didTapNextEventBanner) + + // assert + await store.receive(.map(.focusNextRide(Ride.mock1.coordinate))) + await store.receive(.set(\.$bottomSheetPosition, .relative(0.3))) + } } // MARK: Helper diff --git a/CriticalMapsKit/Tests/MapFeatureTests/UserTrackingButtonSnapshotTests.swift b/CriticalMapsKit/Tests/MapFeatureTests/UserTrackingButtonSnapshotTests.swift index 786cdfc9..66671c46 100644 --- a/CriticalMapsKit/Tests/MapFeatureTests/UserTrackingButtonSnapshotTests.swift +++ b/CriticalMapsKit/Tests/MapFeatureTests/UserTrackingButtonSnapshotTests.swift @@ -1,10 +1,10 @@ import MapFeature import SnapshotTesting import TestHelper -import XCTest +import Testing -final class UserTrackingButtonSnapshotTests: XCTestCase { - @MainActor +struct UserTrackingButtonSnapshotTests { + @Test(.disabled("Due to CI issue with selecting iPhone")) func test_userTracking_none() { let sut = UserTrackingButton( store: .init( @@ -16,7 +16,7 @@ final class UserTrackingButtonSnapshotTests: XCTestCase { assertSnapshot(of: sut, as: .image) } - @MainActor + @Test(.disabled("Due to CI issue with selecting iPhone")) func test_userTracking_follow() { let sut = UserTrackingButton( store: .init( @@ -28,7 +28,7 @@ final class UserTrackingButtonSnapshotTests: XCTestCase { assertSnapshot(of: sut, as: .image) } - @MainActor + @Test(.disabled("Due to CI issue with selecting iPhone")) func test_userTracking_followWithHeading() { let sut = UserTrackingButton( store: .init( diff --git a/CriticalMapsKit/Tests/NextRideFeatureTests/RideTests.swift b/CriticalMapsKit/Tests/NextRideFeatureTests/RideTests.swift deleted file mode 100644 index 0efe1067..00000000 --- a/CriticalMapsKit/Tests/NextRideFeatureTests/RideTests.swift +++ /dev/null @@ -1,90 +0,0 @@ -import ComposableArchitecture -import Helpers -import SharedModels -import XCTest - -final class RideTests: XCTestCase { - func test_rideInGMTTimezone() { - // arrange - let ride = Ride.mock() - - // act - withDependencies { - $0.timeZone = .gmt - } operation: { - XCTAssertEqual( - "20:00", - ride.dateTime.humanReadableTime - ) - } - } - - func test_rideInGermanTimezone() { - // arrange - let ride = Ride.mock() - - // act - withDependencies { - $0.timeZone = .germany - } operation: { - XCTAssertEqual( - "20:00", - ride.dateTime.humanReadableTime - ) - } - } - - func test_rideInGreeceTimezone() { - // arrange - let ride = Ride.mock() - - // act - withDependencies { - $0.timeZone = .greece - $0.calendar = .init(identifier: .gregorian) - } operation: { - XCTAssertEqual( - "20:00", - ride.dateTime.humanReadableTime - ) - } - } - - func test_rideInEcuadorTimezone() { - // arrange - let ride = Ride.mock() - - // act - withDependencies { - $0.timeZone = .ecuador - $0.calendar = .init(identifier: .gregorian) - } operation: { - XCTAssertEqual( - "20:00", - ride.dateTime.humanReadableTime - ) - } - } -} - -extension Ride { - static func mock() -> Self { - Self( - id: 0, - slug: nil, - title: "CriticalMaps Berlin", - description: nil, - dateTime: Date(timeIntervalSince1970: 1711738800), - location: nil, - latitude: 53.1235, - longitude: 13.4234, - estimatedParticipants: nil, - estimatedDistance: nil, - estimatedDuration: nil, - enabled: true, - disabledReason: nil, - disabledReasonMessage: nil, - rideType: .criticalMass - ) - } -} diff --git a/CriticalMapsKit/Tests/NextRideFeatureTests/RideTimeTests.swift b/CriticalMapsKit/Tests/NextRideFeatureTests/RideTimeTests.swift new file mode 100644 index 00000000..fafd59e4 --- /dev/null +++ b/CriticalMapsKit/Tests/NextRideFeatureTests/RideTimeTests.swift @@ -0,0 +1,63 @@ +import ComposableArchitecture +import Foundation +import Helpers +import SharedModels +import Testing + +struct RideTimeTests { + @Test("Ride in new york timezone") + func rideWithNewYorkTimezone() { + withDependencies { + $0.locale = Locale(identifier: "en_US") + } operation: { + let ride = Ride.mock(timeZone: .newYork, timestamp: 1727478000) + let rideTime = ride.rideTime + #expect(rideTime == "7:00 PM") + } + } + + @Test("Ride in berlin timezone") + func rideWithBerlinTimezone() { + withDependencies { + $0.locale = Locale(identifier: "de_DE") + } operation: { + let ride = Ride.mock(timeZone: .germany, timestamp: 1725192000) + let rideTime = ride.rideTime + #expect(rideTime == "14:00") + } + } + + @Test("Ride in GMT timezone") + func rideWithGMTTimezone() { + withDependencies { + $0.locale = Locale(identifier: "pt_PT") + } operation: { + let ride = Ride.mock(timeZone: .gmt, timestamp: 1727452800) + let rideTime = ride.rideTime + #expect(rideTime == "16:00") + } + } +} + +private extension Ride { + static func mock(timeZone: TimeZone, timestamp: TimeInterval) -> Self { + Self( + id: 0, + city: Ride.City(id: 1, name: "Berlin", timezone: timeZone.identifier), + slug: nil, + title: "CriticalMaps Berlin", + description: nil, + dateTime: Date(timeIntervalSince1970: timestamp), + location: nil, + latitude: 53.1235, + longitude: 13.4234, + estimatedParticipants: nil, + estimatedDistance: nil, + estimatedDuration: nil, + enabled: true, + disabledReason: nil, + disabledReasonMessage: nil, + rideType: .criticalMass + ) + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 45b579d9..001e4c93 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -13,7 +13,7 @@ platform :ios do scan( project: "CriticalMaps.xcodeproj", scheme: Scheme, - device: 'iPhone 15', + device: 'iPhone 16', result_bundle: true, xcargs: '-skipPackagePluginValidation -skipMacroValidation' )