From eb65619979854dd58a79472a5d4982fb1b4db70e Mon Sep 17 00:00:00 2001 From: Alfonso Grillo Date: Thu, 29 Jun 2023 11:12:42 +0200 Subject: [PATCH] Open map from timeline (#1199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add navigation to expaneded map * Add MapLibreMapView.Options * Add AppActivityView * Add ShareToMapsAppActivity * Add share sheet presentation * Add localisations * Cleanup * Fix UT build errors * Revert breaking change * Fix UIView setup * Add support for location’s description * Show popover on iPad * Restore assets * More cleanup --- ElementX.xcodeproj/project.pbxproj | 8 ++ .../en.lproj/Localizable.strings | 4 + .../RoomFlowCoordinator.swift | 30 ++--- ElementX/Sources/Generated/Strings.swift | 8 ++ .../Other/MapLibre/LocationAnnotation.swift | 73 +++--------- .../Other/MapLibre/MapLibreMapView.swift | 45 +++++-- .../Other/MapLibre/MapLibreModels.swift | 4 +- .../Other/ShareToMapsAppActivity.swift | 92 +++++++++++++++ .../Other/SwiftUI/Views/AppActivityView.swift | 65 ++++++++++ .../LocationSharingScreenModels.swift | 79 +++++++++++++ .../StaticLocationScreenCoordinator.swift | 6 +- .../StaticLocationScreenViewModel.swift | 4 +- .../View/StaticLocationScreen.swift | 111 ++++++++++++++---- .../RoomScreen/RoomScreenCoordinator.swift | 3 + .../Screens/RoomScreen/RoomScreenModels.swift | 1 + .../RoomScreen/RoomScreenViewModel.swift | 2 + .../Services/Room/RoomProxyProtocol.swift | 1 - .../Sources/Services/Timeline/GeoURI.swift | 18 ++- .../RoomTimelineController.swift | 59 ++++++---- .../RoomTimelineControllerProtocol.swift | 1 + UnitTests/Sources/GeoURITests.swift | 23 ++-- .../StaticLocationScreenViewModelTests.swift | 2 +- 22 files changed, 489 insertions(+), 150 deletions(-) create mode 100644 ElementX/Sources/Other/ShareToMapsAppActivity.swift create mode 100644 ElementX/Sources/Other/SwiftUI/Views/AppActivityView.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index a110e2b14..708f6e490 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -75,6 +75,7 @@ 1B88BB631F7FC45A213BB554 /* TimelineItemSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55AEEF8142DF1B59DB40FB93 /* TimelineItemSender.swift */; }; 1BA04D05EBC6646958B1BE60 /* PlaceholderScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF34A2FD6797535C95AC918D /* PlaceholderScreenCoordinator.swift */; }; 1C409A26A99F0371C47AFA51 /* UserDiscoveryServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F615A00DB223FF3280204D2 /* UserDiscoveryServiceProtocol.swift */; }; + 1C8BC70A18060677E295A846 /* ShareToMapsAppActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */; }; 1C9BB74711E5F24C77B7FED0 /* RoomMembersListScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.swift */; }; 1D69E31913DF66426985909B /* EmojiPickerScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11151E78D6BB2B04A8FBD389 /* EmojiPickerScreenViewModelProtocol.swift */; }; 1E59B77A0B2CE83DCC1B203C /* LoginViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A05707BF550D770168A406DB /* LoginViewModelTests.swift */; }; @@ -370,6 +371,7 @@ 8B41D0357B91CD3B6F6A3BCA /* EmoteRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */; }; 8B76191B9DDD1AC90A6E3A35 /* MediaFileHandleProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC1D382565A4E9CAC2F14EA /* MediaFileHandleProxy.swift */; }; 8BC8EF6705A78946C1F22891 /* SoftLogoutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.swift */; }; + 8C1A5ECAF895D4CAF8C4D461 /* AppActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F21ED7205048668BEB44A38 /* AppActivityView.swift */; }; 8C454500B8073E1201F801A9 /* MXLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A34A814CBD56230BC74FFCF4 /* MXLogger.swift */; }; 8CC12086CBF91A7E10CDC205 /* HomeScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653265D006E708E4E51AD64 /* HomeScreenCoordinator.swift */; }; 8D0C5BC670D514760CC84E2A /* TextBasedRoomTimelineViewMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A542BC40D6EC2E66BC5659B /* TextBasedRoomTimelineViewMock.swift */; }; @@ -931,6 +933,7 @@ 42ADEA322D2089391E049535 /* InvitesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreen.swift; sourceTree = ""; }; 42C64A14EE89928207E3B42B /* AnalyticsSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenModels.swift; sourceTree = ""; }; 42EEA67A6796BDC2761619C5 /* PaginationIndicatorRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIndicatorRoomTimelineView.swift; sourceTree = ""; }; + 4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareToMapsAppActivity.swift; sourceTree = ""; }; 44D8C8431416EB8DFEC7E235 /* ApplicationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationTests.swift; sourceTree = ""; }; 450E04B2A976CC4C8CC1807C /* EmoteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItem.swift; sourceTree = ""; }; 4549FCB53F43DB0B278374BC /* TemplateScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreen.swift; sourceTree = ""; }; @@ -1109,6 +1112,7 @@ 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = ""; }; 8E1BBA73B611EDEEA6E20E05 /* InvitesScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenModels.swift; sourceTree = ""; }; 8EC57A32ABC80D774CC663DB /* SettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenUITests.swift; sourceTree = ""; }; + 8F21ED7205048668BEB44A38 /* AppActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppActivityView.swift; sourceTree = ""; }; 8F61A0DD8243B395499C99A2 /* InvitesScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenUITests.swift; sourceTree = ""; }; 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = ""; }; 8FC26871038FB0E4AAE22605 /* apple_emojis_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = apple_emojis_data.json; sourceTree = ""; }; @@ -1785,6 +1789,7 @@ 328DD5DA1281F758B72006C7 /* Views */ = { isa = PBXGroup; children = ( + 8F21ED7205048668BEB44A38 /* AppActivityView.swift */, CC743C7A85E3171BCBF0A653 /* AvatarHeaderView.swift */, 0960A7F5C1B0B6679BDF26F9 /* ElementToggleStyle.swift */, B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */, @@ -3036,6 +3041,7 @@ F754E66A8970963B15B2A41E /* PermalinkBuilder.swift */, 53482ECA4B6633961EC224F5 /* ScrollViewAdapter.swift */, DBA8DC95C079805B0B56E8A9 /* SharedUserDefaultsKeys.swift */, + 4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */, BB3073CCD77D906B330BC1D6 /* Tests.swift */, 1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */, 35FA991289149D31F4286747 /* UserPreference.swift */, @@ -3978,6 +3984,7 @@ 7C6376192F578E0BA801BFEC /* AnalyticsSettingsScreenModels.swift in Sources */, A74438ED16F8683A4B793E6A /* AnalyticsSettingsScreenViewModel.swift in Sources */, 654E802C127B84554042903E /* AnalyticsSettingsScreenViewModelProtocol.swift in Sources */, + 8C1A5ECAF895D4CAF8C4D461 /* AppActivityView.swift in Sources */, 095C0ACFC234E0550A6404C5 /* AppCoordinator.swift in Sources */, A021827B528F1EDC9101CA58 /* AppCoordinatorProtocol.swift in Sources */, 4FF90E2242DBD596E1ED2E27 /* AppCoordinatorStateMachine.swift in Sources */, @@ -4317,6 +4324,7 @@ B93D7CE520088AD53FA6D53C /* SettingsScreenModels.swift in Sources */, E0B6A569AC3E81D233B43D60 /* SettingsScreenViewModel.swift in Sources */, A009BDFB0A6816D4C392ADCB /* SettingsScreenViewModelProtocol.swift in Sources */, + 1C8BC70A18060677E295A846 /* ShareToMapsAppActivity.swift in Sources */, 8922219C5C934C4155E8CA50 /* SharedUserDefaultsKeys.swift in Sources */, 274CE3C986841D15FD530BF5 /* ShimmerModifier.swift in Sources */, 8BC8EF6705A78946C1F22891 /* SoftLogoutScreen.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index cc079d663..56fe971c3 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -319,11 +319,15 @@ "screen_session_verification_waiting_to_accept_title" = "Waiting to accept request"; "screen_share_location_title" = "Share location"; "screen_share_my_location_action" = "Share my location"; +"screen_share_open_apple_maps" = "Open in Apple Maps"; +"screen_share_open_google_maps" = "Open in Google Maps"; +"screen_share_open_osm_maps" = "Open in OpenStreetMap"; "screen_share_this_location_action" = "Share this location"; "screen_signout_confirmation_dialog_content" = "Are you sure you want to sign out?"; "screen_signout_confirmation_dialog_title" = "Sign out"; "screen_signout_in_progress_dialog_content" = "Signing out…"; "screen_start_chat_error_starting_chat" = "An error occurred when trying to start a chat"; +"screen_view_location_title" = "Location"; "screen_waitlist_message" = "There's a high demand for %1$@ on %2$@ at the moment. Come back to the app in a few days and try again.\n\nThanks for your patience!"; "screen_waitlist_message_success" = "Welcome to %1$@"; "screen_waitlist_title" = "You're on the waitlist!"; diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 6c23af6cf..f3e5fccda 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -145,9 +145,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { return .messageForwarding(roomID: roomID, itemID: itemID) case (.dismissMessageForwarding, .messageForwarding(let roomID, _)): return .room(roomID: roomID) - case (.presentLocationPicker, .room(let roomID)): - return .locationPicker(roomID: roomID) - case (.dismissLocationPicker, .locationPicker(let roomID)): + case (.presentMapNavigator, .room(let roomID)): + return .mapNavigator(roomID: roomID) + case (.dismissMapNavigator, .mapNavigator(let roomID)): return .room(roomID: roomID) default: return nil @@ -211,9 +211,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { presentMessageForwarding(for: eventID) case (.messageForwarding, .dismissMessageForwarding, .room): break - case (.room, .presentLocationPicker, .locationPicker): - presentLocationPicker() - case (.locationPicker, .dismissLocationPicker, .room): + case (.room, .presentMapNavigator(let mode), .mapNavigator): + presentMapNavigator(interactionMode: mode) + case (.mapNavigator, .dismissMapNavigator, .room): break default: fatalError("Unknown transition: \(context)") @@ -307,7 +307,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case .presentEmojiPicker(let itemID): stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID)) case .presentLocationPicker: - stateMachine.tryEvent(.presentLocationPicker) + stateMachine.tryEvent(.presentMapNavigator(interactionMode: .picker)) + case .presentLocationViewer(_, let geoURI): + stateMachine.tryEvent(.presentMapNavigator(interactionMode: .viewOnly(geoURI: geoURI))) case .presentRoomMemberDetails(member: let member): stateMachine.tryEvent(.presentRoomMemberDetails(member: .init(value: member))) case .presentMessageForwarding(let itemID): @@ -500,10 +502,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } } - private func presentLocationPicker() { + private func presentMapNavigator(interactionMode: StaticLocationInteractionMode) { let locationPickerNavigationStackCoordinator = NavigationStackCoordinator() - let params = StaticLocationScreenCoordinatorParameters() + let params = StaticLocationScreenCoordinatorParameters(interactionMode: interactionMode) let coordinator = StaticLocationScreenCoordinator(parameters: params) coordinator.actions.sink { [weak self] action in @@ -519,11 +521,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } } .store(in: &cancellables) - + locationPickerNavigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setSheetCoordinator(locationPickerNavigationStackCoordinator) { [weak self] in - self?.stateMachine.tryEvent(.dismissLocationPicker) + self?.stateMachine.tryEvent(.dismissMapNavigator) } } @@ -618,7 +620,7 @@ private extension RoomFlowCoordinator { case mediaUploadPicker(roomID: String, source: MediaPickerScreenSource) case mediaUploadPreview(roomID: String, fileURL: URL) case emojiPicker(roomID: String, itemID: String) - case locationPicker(roomID: String) + case mapNavigator(roomID: String) case roomMemberDetails(roomID: String, member: HashableRoomMemberWrapper) case messageForwarding(roomID: String, itemID: String) } @@ -647,8 +649,8 @@ private extension RoomFlowCoordinator { case presentEmojiPicker(itemID: String) case dismissEmojiPicker - case presentLocationPicker - case dismissLocationPicker + case presentMapNavigator(interactionMode: StaticLocationInteractionMode) + case dismissMapNavigator case presentRoomMemberDetails(member: HashableRoomMemberWrapper) case dismissRoomMemberDetails diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index f27949e7d..5e4bb0b04 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -804,6 +804,12 @@ public enum L10n { public static var screenShareLocationTitle: String { return L10n.tr("Localizable", "screen_share_location_title") } /// Share my location public static var screenShareMyLocationAction: String { return L10n.tr("Localizable", "screen_share_my_location_action") } + /// Open in Apple Maps + public static var screenShareOpenAppleMaps: String { return L10n.tr("Localizable", "screen_share_open_apple_maps") } + /// Open in Google Maps + public static var screenShareOpenGoogleMaps: String { return L10n.tr("Localizable", "screen_share_open_google_maps") } + /// Open in OpenStreetMap + public static var screenShareOpenOsmMaps: String { return L10n.tr("Localizable", "screen_share_open_osm_maps") } /// Share this location public static var screenShareThisLocationAction: String { return L10n.tr("Localizable", "screen_share_this_location_action") } /// Are you sure you want to sign out? @@ -818,6 +824,8 @@ public enum L10n { public static var screenSignoutPreferenceItem: String { return L10n.tr("Localizable", "screen_signout_preference_item") } /// An error occurred when trying to start a chat public static var screenStartChatErrorStartingChat: String { return L10n.tr("Localizable", "screen_start_chat_error_starting_chat") } + /// Location + public static var screenViewLocationTitle: String { return L10n.tr("Localizable", "screen_view_location_title") } /// There's a high demand for %1$@ on %2$@ at the moment. Come back to the app in a few days and try again. /// /// Thanks for your patience! diff --git a/ElementX/Sources/Other/MapLibre/LocationAnnotation.swift b/ElementX/Sources/Other/MapLibre/LocationAnnotation.swift index 07bf9181b..915bf8c25 100644 --- a/ElementX/Sources/Other/MapLibre/LocationAnnotation.swift +++ b/ElementX/Sources/Other/MapLibre/LocationAnnotation.swift @@ -18,83 +18,42 @@ import Foundation import Mapbox import SwiftUI -/// Base class to handle a map annotation -class LocationAnnotation: NSObject, MGLAnnotation { - // MARK: - Properties - - // Title property is needed to enable annotation selection and callout view showing - var title: String? - +final class LocationAnnotation: NSObject, MGLAnnotation { let coordinate: CLLocationCoordinate2D + let anchorPoint: CGPoint + let view: AnyView // MARK: - Setup - init(coordinate: CLLocationCoordinate2D) { + init(coordinate: CLLocationCoordinate2D, + anchorPoint: CGPoint = .init(x: 0.5, y: 0.5), + @ViewBuilder label: () -> some View) { self.coordinate = coordinate + self.anchorPoint = anchorPoint + view = AnyView(label()) super.init() } } -/// POI map annotation -class PinLocationAnnotation: LocationAnnotation { } - -class LocationAnnotationView: MGLUserLocationAnnotationView { - private enum Constants { - static let defaultFrame = CGRect(x: 0, y: 0, width: 46, height: 46) - } - +final class LocationAnnotationView: MGLUserLocationAnnotationView { // MARK: - Setup override init(annotation: MGLAnnotation?, reuseIdentifier: String?) { super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - frame = Constants.defaultFrame } - convenience init(userPinLocationAnnotation: MGLAnnotation) { - self.init(annotation: userPinLocationAnnotation, reuseIdentifier: "userPinLocation") - - addUserView() - } - - convenience init(pinLocationAnnotation: PinLocationAnnotation) { - self.init(annotation: pinLocationAnnotation, reuseIdentifier: nil) - - addPinView() + convenience init(annotation: LocationAnnotation) { + self.init(annotation: annotation, reuseIdentifier: "\(Self.self)") + let view: UIView = UIHostingController(rootView: annotation.view).view + view.backgroundColor = .clear + view.anchorPoint = annotation.anchorPoint + addSubview(view) + view.bounds.size = view.intrinsicContentSize } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } - - // MARK: - Private - - private func addUserView() { - guard let pinView = UIHostingController(rootView: Image(systemName: "circle.fill") - .resizable() - .foregroundColor(.compound.iconPrimary)).view else { - return - } - - addMarkerView(pinView) - } - - private func addPinView() { - guard let pinView = UIHostingController(rootView: Image(systemName: "mappin") - .resizable() - .foregroundColor(.compound.iconPrimary)).view else { - return - } - - addMarkerView(pinView) - } - - private func addMarkerView(_ markerView: UIView) { - markerView.backgroundColor = .clear - - addSubview(markerView) - - markerView.frame = bounds - } } diff --git a/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift b/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift index 13e90747b..ee0e2947c 100644 --- a/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift +++ b/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift @@ -19,11 +19,19 @@ import Mapbox import SwiftUI struct MapLibreMapView: UIViewRepresentable { - // MARK: - Constants - - private enum Constants { - static let mapZoomLevel = 15.0 - static let mapZoomLevelWithoutPermission = 5.0 + struct Options { + /// The initial zoom level + let zoomLevel: Double + /// The initial map center + let mapCenter: CLLocationCoordinate2D? + /// Map annotations + let annotations: [LocationAnnotation] + + init(zoomLevel: Double, mapCenter: CLLocationCoordinate2D? = nil, annotations: [LocationAnnotation] = []) { + self.zoomLevel = zoomLevel + self.mapCenter = mapCenter + self.annotations = annotations + } } // MARK: - Properties @@ -31,6 +39,8 @@ struct MapLibreMapView: UIViewRepresentable { @Environment(\.colorScheme) private var colorScheme let builder: MapTilerStyleBuilderProtocol + + let options: Options /// Behavior mode of the current user's location, can be hidden, only shown and shown following the user var showsUserLocationMode: ShowUserLocationMode = .hide @@ -52,12 +62,13 @@ struct MapLibreMapView: UIViewRepresentable { let panGesture = UIPanGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.didPan)) panGesture.delegate = context.coordinator mapView.addGestureRecognizer(panGesture) - mapView.zoomLevel = Constants.mapZoomLevelWithoutPermission + setupMap(mapView: mapView, with: options) return mapView } func updateUIView(_ mapView: MGLMapView, context: Context) { mapView.removeAllAnnotations() + mapView.addAnnotations(options.annotations) if colorScheme == .dark { mapView.styleURL = builder.dynamicMapURL(for: .dark) @@ -73,6 +84,13 @@ struct MapLibreMapView: UIViewRepresentable { } // MARK: - Private + + private func setupMap(mapView: MGLMapView, with options: Options) { + mapView.zoomLevel = options.zoomLevel + if let mapCenter = options.mapCenter { + mapView.centerCoordinate = mapCenter + } + } private func makeMapView() -> MGLMapView { let mapView = MGLMapView(frame: .zero, styleURL: colorScheme == .dark ? builder.dynamicMapURL(for: .dark) : builder.dynamicMapURL(for: .light)) @@ -85,7 +103,7 @@ struct MapLibreMapView: UIViewRepresentable { private func showUserLocation(in mapView: MGLMapView) { switch showsUserLocationMode { - case .follow: + case .showAndFollow: mapView.showsUserLocation = true mapView.userTrackingMode = .follow case .show: @@ -115,12 +133,10 @@ extension MapLibreMapView { // MARK: - MGLMapViewDelegate func mapView(_ mapView: MGLMapView, viewFor annotation: MGLAnnotation) -> MGLAnnotationView? { - if let pinLocationAnnotation = annotation as? PinLocationAnnotation { - return LocationAnnotationView(pinLocationAnnotation: pinLocationAnnotation) - } else if annotation is MGLUserLocation { - return LocationAnnotationView(userPinLocationAnnotation: annotation) + guard let annotation = annotation as? LocationAnnotation else { + return nil } - return nil + return LocationAnnotationView(annotation: annotation) } func mapViewDidFailLoadingMap(_ mapView: MGLMapView, withError error: Error) { @@ -143,7 +159,10 @@ extension MapLibreMapView { } func mapView(_ mapView: MGLMapView, regionDidChangeAnimated animated: Bool) { - mapLibreView.mapCenterCoordinate = mapView.centerCoordinate + // Fixes: "Publishing changes from within view updates is not allowed, this will cause undefined behavior." + DispatchQueue.main.async { [mapLibreView] in + mapLibreView.mapCenterCoordinate = mapView.centerCoordinate + } } // MARK: Callout diff --git a/ElementX/Sources/Other/MapLibre/MapLibreModels.swift b/ElementX/Sources/Other/MapLibre/MapLibreModels.swift index 21f0b2069..538af291a 100644 --- a/ElementX/Sources/Other/MapLibre/MapLibreModels.swift +++ b/ElementX/Sources/Other/MapLibre/MapLibreModels.swift @@ -20,10 +20,10 @@ import Foundation Behavior mode of the current user's location, can be hidden, only shown and shown following the user */ enum ShowUserLocationMode { - /// this mode will show the user pin in map and track him, panning the map automatically - case follow /// this mode will show the user pin in map case show + /// this mode will show the user pin in map and track him, panning the map automatically + case showAndFollow /// this mode will not show the user pin in map case hide } diff --git a/ElementX/Sources/Other/ShareToMapsAppActivity.swift b/ElementX/Sources/Other/ShareToMapsAppActivity.swift new file mode 100644 index 000000000..b03b28917 --- /dev/null +++ b/ElementX/Sources/Other/ShareToMapsAppActivity.swift @@ -0,0 +1,92 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import CoreLocation +import UIKit + +final class ShareToMapsAppActivity: UIActivity { + enum MapsAppType: CaseIterable { + case apple + case google + case osm + } + + private let type: MapsAppType + private let location: CLLocationCoordinate2D + + init(type: MapsAppType, location: CLLocationCoordinate2D) { + self.type = type + self.location = location + super.init() + } + + override private init() { + fatalError() + } + + override var activityTitle: String? { + type.activityTitle + } + + var activityCategory: UIActivity.Category { + .action + } + + override var activityType: UIActivity.ActivityType { + .shareToMapsApp + } + + override func canPerform(withActivityItems activityItems: [Any]) -> Bool { + true + } + + override func prepare(withActivityItems activityItems: [Any]) { + UIApplication.shared.open(type.activityURL(for: location), options: [:]) { [weak self] result in + self?.activityDidFinish(result) + } + } +} + +extension ShareToMapsAppActivity.MapsAppType { + func activityURL(for location: CLLocationCoordinate2D) -> URL { + switch self { + case .apple: + // swiftlint:disable:next force_unwrapping + return URL(string: "https://maps.apple.com?ll=\(location.latitude),\(location.longitude)&q=Pin")! + case .google: + // swiftlint:disable:next force_unwrapping + return URL(string: "https://www.google.com/maps/search/?api=1&query=\(location.latitude),\(location.longitude)")! + case .osm: + // swiftlint:disable:next force_unwrapping + return URL(string: "https://www.openstreetmap.org/?mlat=\(location.latitude)&mlon=\(location.longitude)")! + } + } + + var activityTitle: String { + switch self { + case .apple: + return L10n.screenShareOpenAppleMaps + case .google: + return L10n.screenShareOpenGoogleMaps + case .osm: + return L10n.screenShareOpenOsmMaps + } + } +} + +private extension UIActivity.ActivityType { + static let shareToMapsApp = UIActivity.ActivityType("ElementX.ShareToMapsApp") +} diff --git a/ElementX/Sources/Other/SwiftUI/Views/AppActivityView.swift b/ElementX/Sources/Other/SwiftUI/Views/AppActivityView.swift new file mode 100644 index 000000000..2e1dcbbd9 --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Views/AppActivityView.swift @@ -0,0 +1,65 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct AppActivityView: UIViewControllerRepresentable { + typealias UIViewControllerType = UIActivityViewController + typealias CompletionType = (Result<(activity: UIActivity.ActivityType, items: [Any]?), Error>) -> Void + + private let activityItems: [Any] + private let applicationActivities: [UIActivity]? + private var excludedActivityTypes: [UIActivity.ActivityType] + private var onCancel: (() -> Void)? + private var onComplete: CompletionType? + + public init(activityItems: [Any], + applicationActivities: [UIActivity]? = nil, + excludedActivityTypes: [UIActivity.ActivityType] = [], + onCancel: (() -> Void)? = nil, + onComplete: CompletionType? = nil) { + self.activityItems = activityItems + self.applicationActivities = applicationActivities + self.excludedActivityTypes = excludedActivityTypes + self.onCancel = onCancel + self.onComplete = onComplete + } + + public func makeUIViewController(context: Context) -> UIViewControllerType { + let viewController = UIViewControllerType(activityItems: activityItems, applicationActivities: applicationActivities) + viewController.excludedActivityTypes = excludedActivityTypes + + viewController.completionWithItemsHandler = { activity, completed, items, error in + if let error { + onComplete?(.failure(error)) + } else if let activity, completed { + onComplete?(.success((activity, items))) + } else if !completed { + onCancel?() + } else { + assertionFailure() + } + } + + return viewController + } + + public func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { } + + public static func dismantleUIViewController(_ uiViewController: UIViewControllerType, coordinator: Coordinator) { + uiViewController.completionWithItemsHandler = nil + } +} diff --git a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift index 654d2b23a..52df3f948 100644 --- a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift +++ b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift @@ -27,13 +27,90 @@ enum StaticLocationScreenViewModelAction { case sendLocation(GeoURI) } +enum StaticLocationInteractionMode: Hashable { + case picker + case viewOnly(geoURI: GeoURI, description: String? = nil) +} + struct StaticLocationScreenViewState: BindableState { + init(interactionMode: StaticLocationInteractionMode, isPinDropSharing: Bool = true, showsUserLocationMode: ShowUserLocationMode = .hide) { + self.interactionMode = interactionMode + self.isPinDropSharing = isPinDropSharing + self.showsUserLocationMode = showsUserLocationMode + + switch interactionMode { + case .picker: + bindings = .init() + case .viewOnly(let geoURI, _): + bindings = .init(mapCenterLocation: .init(latitude: geoURI.latitude, longitude: geoURI.longitude)) + } + } + + let interactionMode: StaticLocationInteractionMode /// Indicates whether the user has moved around the map to drop a pin somewhere other than their current location var isPinDropSharing = true /// Behavior mode of the current user's location, can be hidden, only shown and shown following the user var showsUserLocationMode: ShowUserLocationMode = .hide var bindings = StaticLocationScreenBindings() + + var showBottomToolbar: Bool { + interactionMode == .picker + } + + var mapAnnotationCoordinate: CLLocationCoordinate2D? { + switch interactionMode { + case .picker: + return nil + case .viewOnly(let geoURI, _): + return .init(latitude: geoURI.latitude, longitude: geoURI.longitude) + } + } + + var showPinInTheCenter: Bool { + switch interactionMode { + case .picker: + return isPinDropSharing + case .viewOnly: + return false + } + } + + var navigationTitle: String { + switch interactionMode { + case .picker: + return L10n.screenShareLocationTitle + case .viewOnly: + return L10n.screenViewLocationTitle + } + } + + var showShareAction: Bool { + switch interactionMode { + case .picker: + return false + case .viewOnly: + return true + } + } + + var zoomLevel: Double { + switch interactionMode { + case .picker: + return 5.0 + case .viewOnly: + return 15.0 + } + } + + var locationDescription: String? { + switch interactionMode { + case .picker: + return nil + case .viewOnly(_, let description): + return description + } + } } struct StaticLocationScreenBindings { @@ -54,6 +131,8 @@ struct StaticLocationScreenBindings { /// Information describing the currently displayed alert. var alertInfo: AlertInfo? + + var showShareSheet = false } enum StaticLocationScreenViewAction { diff --git a/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenCoordinator.swift b/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenCoordinator.swift index d5a0cbc9a..a160bb8aa 100644 --- a/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenCoordinator.swift +++ b/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenCoordinator.swift @@ -17,7 +17,9 @@ import Combine import SwiftUI -struct StaticLocationScreenCoordinatorParameters { } +struct StaticLocationScreenCoordinatorParameters { + let interactionMode: StaticLocationInteractionMode +} enum StaticLocationScreenCoordinatorAction { case close @@ -38,7 +40,7 @@ final class StaticLocationScreenCoordinator: CoordinatorProtocol { init(parameters: StaticLocationScreenCoordinatorParameters) { self.parameters = parameters - viewModel = StaticLocationScreenViewModel() + viewModel = StaticLocationScreenViewModel(interactionMode: parameters.interactionMode) } // MARK: - Public diff --git a/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift b/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift index 14fcc5193..4776fe19f 100644 --- a/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift +++ b/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift @@ -26,8 +26,8 @@ class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLo actionsSubject.eraseToAnyPublisher() } - init() { - super.init(initialViewState: StaticLocationScreenViewState()) + init(interactionMode: StaticLocationInteractionMode) { + super.init(initialViewState: .init(interactionMode: interactionMode)) } override func process(viewAction: StaticLocationScreenViewAction) { diff --git a/ElementX/Sources/Screens/LocationSharing/View/StaticLocationScreen.swift b/ElementX/Sources/Screens/LocationSharing/View/StaticLocationScreen.swift index 3b1a7609c..291ce5fc2 100644 --- a/ElementX/Sources/Screens/LocationSharing/View/StaticLocationScreen.swift +++ b/ElementX/Sources/Screens/LocationSharing/View/StaticLocationScreen.swift @@ -22,43 +22,81 @@ struct StaticLocationScreen: View { private let builder = MapTilerStyleBuilder(appSettings: ServiceLocator.shared.settings) var body: some View { - mapView - .ignoresSafeArea(.all, edges: .horizontal) - .navigationTitle(L10n.screenShareLocationTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { toolbar } - .alert(item: $context.alertInfo) + VStack(spacing: 0) { + if let locationDescription = context.viewState.locationDescription { + Text(locationDescription) + .lineLimit(2) + .foregroundColor(Color.compound.textPrimary) + .font(.compound.bodyMD) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + mapView + } + .navigationTitle(context.viewState.navigationTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbar } + .alert(item: $context.alertInfo) } private var mapView: some View { ZStack(alignment: .center) { MapLibreMapView(builder: builder, + options: mapOptions, showsUserLocationMode: .hide, error: $context.mapError, mapCenterCoordinate: $context.mapCenterLocation, userDidPan: { context.send(viewAction: .userDidPan) }) - if context.viewState.isPinDropSharing { + if context.viewState.showPinInTheCenter { LocationMarkerView() } } + .ignoresSafeArea(.all, edges: mapSafeAreaEdges) } + + // MARK: - Private @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .navigationBarLeading) { closeButton } - - ToolbarItemGroup(placement: .bottomBar) { - shareLocationButton - Spacer() + + if context.viewState.showShareAction { + ToolbarItem(placement: .navigationBarTrailing) { + shareButton + .popover(isPresented: $context.showShareSheet) { shareSheet } + } } + + if context.viewState.showBottomToolbar { + ToolbarItemGroup(placement: .bottomBar) { + selectLocationButton + Spacer() + } + } + } + + private var mapOptions: MapLibreMapView.Options { + guard let coordinate = context.viewState.mapAnnotationCoordinate else { + return .init(zoomLevel: context.viewState.zoomLevel) + } + + return .init(zoomLevel: context.viewState.zoomLevel, + mapCenter: coordinate, + annotations: [LocationAnnotation(coordinate: coordinate, anchorPoint: .bottomCenter) { + LocationMarkerView() + }]) + } + + private var mapSafeAreaEdges: Edge.Set { + context.viewState.showBottomToolbar ? .horizontal : [.horizontal, .bottom] } @ScaledMetric private var shareMarkerSize: CGFloat = 28 - private var shareLocationButton: some View { + private var selectLocationButton: some View { Button { context.send(viewAction: .selectLocation) } label: { @@ -73,25 +111,52 @@ struct StaticLocationScreen: View { } private var closeButton: some View { - Button(L10n.actionCancel, action: close) + Button(L10n.actionCancel) { + context.send(viewAction: .close) + } } - - private func close() { - context.send(viewAction: .close) + + private var shareButton: some View { + Button { + context.showShareSheet = true + } label: { + Image(systemName: "square.and.arrow.up") + } + } + + @ViewBuilder + private var shareSheet: some View { + if let location = context.viewState.mapAnnotationCoordinate { + AppActivityView(activityItems: [ShareToMapsAppActivity.MapsAppType.apple.activityURL(for: location)], + applicationActivities: ShareToMapsAppActivity.MapsAppType.allCases.map { ShareToMapsAppActivity(type: $0, location: location) }) + .edgesIgnoringSafeArea(.bottom) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) + } } } // MARK: - Previews struct StaticLocationScreenViewer_Previews: PreviewProvider { - static let viewModel = { - let viewModel = StaticLocationScreenViewModel() - return viewModel - }() - static var previews: some View { - NavigationView { - StaticLocationScreen(context: viewModel.context) + NavigationStack { + StaticLocationScreen(context: StaticLocationScreenViewModel(interactionMode: .picker).context) } + .previewDisplayName("Picker") + + NavigationStack { + StaticLocationScreen(context: StaticLocationScreenViewModel(interactionMode: .viewOnly(geoURI: .init(latitude: 41.9027835, longitude: 12.4963655))).context) + } + .previewDisplayName("View Only") + + NavigationStack { + StaticLocationScreen(context: StaticLocationScreenViewModel(interactionMode: .viewOnly(geoURI: .init(latitude: 41.9027835, longitude: 12.4963655), description: "Cool position")).context) + } + .previewDisplayName("View Only (with description)") } } + +private extension CGPoint { + static let bottomCenter: Self = .init(x: 0.5, y: 1) +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index bc16f2f16..d6a9aedb9 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -30,6 +30,7 @@ enum RoomScreenCoordinatorAction { case presentMediaUploadPreviewScreen(URL) case presentRoomDetails case presentLocationPicker + case presentLocationViewer(body: String, geoURI: GeoURI) case presentEmojiPicker(itemID: String) case presentRoomMemberDetails(member: RoomMemberProxyProtocol) case presentMessageForwarding(itemID: String) @@ -84,6 +85,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.presentRoomMemberDetails(member: member)) case .displayMessageForwarding(let itemID): actionsSubject.send(.presentMessageForwarding(itemID: itemID)) + case .displayLocation(let body, let geoURI): + actionsSubject.send(.presentLocationViewer(body: body, geoURI: geoURI)) } } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 7434ef9b9..53d659664 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -29,6 +29,7 @@ enum RoomScreenViewModelAction { case displayMediaUploadPreviewScreen(url: URL) case displayRoomMemberDetails(member: RoomMemberProxyProtocol) case displayMessageForwarding(itemID: String) + case displayLocation(body: String, geoURI: GeoURI) } enum RoomScreenComposerMode: Equatable { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index f7cb5372c..40781e51e 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -221,6 +221,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol switch action { case .displayMediaFile(let file, let title): state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: title) + case .displayLocation(let body, let geoURI): + callback?(.displayLocation(body: body, geoURI: geoURI)) case .none: break } diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 140f74a54..317171fa5 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -41,7 +41,6 @@ enum RoomProxyError: Error { case failedSettingRoomTopic case failedRemovingAvatar case failedUploadingAvatar - case failedSendingLocation } @MainActor diff --git a/ElementX/Sources/Services/Timeline/GeoURI.swift b/ElementX/Sources/Services/Timeline/GeoURI.swift index aa2ed94b4..997d17b83 100644 --- a/ElementX/Sources/Services/Timeline/GeoURI.swift +++ b/ElementX/Sources/Services/Timeline/GeoURI.swift @@ -43,9 +43,9 @@ struct GeoURI: Hashable { var string: String { if let uncertainty { - return "geo:\(latitude),\(longitude);u=\(uncertainty)" + return "geo:\(string(for: latitude)),\(string(for: longitude));u=\(string(for: uncertainty))" } else { - return "geo:\(latitude),\(longitude)" + return "geo:\(string(for: latitude)),\(string(for: longitude))" } } @@ -64,6 +64,10 @@ struct GeoURI: Hashable { let uncertainty = matchOutput.uncertainty.flatMap(Double.init) return .init(latitude: latitude, longitude: longitude, uncertainty: uncertainty) } + + private func string(for number: Double) -> String { + NumberFormatter.decimal.string(from: .init(floatLiteral: number)) ?? "\(number)" + } } // swiftlint:disable:next large_tuple @@ -78,3 +82,13 @@ extension GeoURI { self.init(latitude: coordinate.latitude, longitude: coordinate.longitude) } } + +private extension NumberFormatter { + static let decimal: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.locale = Locale(identifier: "en_US_POSIX") + numberFormatter.numberStyle = .decimal + numberFormatter.maximumFractionDigits = 30 + return numberFormatter + }() +} diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index 2c4ca073b..ecc0da028 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -115,32 +115,12 @@ class RoomTimelineController: RoomTimelineControllerProtocol { return .none } - var source: MediaSourceProxy? - var body: String switch timelineItem { - case let item as ImageRoomTimelineItem: - source = item.content.source - body = item.content.body - case let item as VideoRoomTimelineItem: - source = item.content.source - body = item.content.body - case let item as FileRoomTimelineItem: - source = item.content.source - body = item.content.body - case let item as AudioRoomTimelineItem: - // For now we are just displaying audio messages with the File preview until we create a timeline player for them. - source = item.content.source - body = item.content.body + case let item as LocationRoomTimelineItem: + guard let geoURI = item.content.geoURI else { return .none } + return .displayLocation(body: item.content.body, geoURI: geoURI) default: - return .none - } - - guard let source else { return .none } - switch await mediaProvider.loadFileFromSource(source, body: body) { - case .success(let file): - return .displayMediaFile(file: file, title: body) - case .failure: - return .none + return await displayMediaActionIfPossible(timelineItem: timelineItem) } } @@ -221,6 +201,37 @@ class RoomTimelineController: RoomTimelineControllerProtocol { // Recompute all attributed strings on content size changes -> DynamicType support updateTimelineItems() } + + private func displayMediaActionIfPossible(timelineItem: RoomTimelineItemProtocol) async -> RoomTimelineControllerAction { + var source: MediaSourceProxy? + var body: String + + switch timelineItem { + case let item as ImageRoomTimelineItem: + source = item.content.source + body = item.content.body + case let item as VideoRoomTimelineItem: + source = item.content.source + body = item.content.body + case let item as FileRoomTimelineItem: + source = item.content.source + body = item.content.body + case let item as AudioRoomTimelineItem: + // For now we are just displaying audio messages with the File preview until we create a timeline player for them. + source = item.content.source + body = item.content.body + default: + return .none + } + + guard let source else { return .none } + switch await mediaProvider.loadFileFromSource(source, body: body) { + case .success(let file): + return .displayMediaFile(file: file, title: body) + case .failure: + return .none + } + } private func updateTimelineItems() { var newTimelineItems = [RoomTimelineItemProtocol]() diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift index fb2bca689..4bfefdd16 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift @@ -26,6 +26,7 @@ enum RoomTimelineControllerCallback { enum RoomTimelineControllerAction { case displayMediaFile(file: MediaFileHandleProxy, title: String?) + case displayLocation(body: String, geoURI: GeoURI) case none } diff --git a/UnitTests/Sources/GeoURITests.swift b/UnitTests/Sources/GeoURITests.swift index 2657def86..750a6e84d 100644 --- a/UnitTests/Sources/GeoURITests.swift +++ b/UnitTests/Sources/GeoURITests.swift @@ -19,36 +19,36 @@ import XCTest final class GeoURITests: XCTestCase { func testValidPositiveCoordinates() throws { - let string = "geo:53.99803101552848,8.25347900390625;u=10.123" + let string = "geo:53.9980310155285,8.25347900390625;u=10.123" let uri = try XCTUnwrap(GeoURI(string: string)) - XCTAssertEqual(uri.latitude, 53.99803101552848) + XCTAssertEqual(uri.latitude, 53.9980310155285) XCTAssertEqual(uri.longitude, 8.25347900390625) XCTAssertEqual(uri.uncertainty, 10.123) XCTAssertEqual(uri.string, string) } func testValidNegativeCoordinates() throws { - let string = "geo:-53.99803101552848,-8.25347900390625;u=10.0" + let string = "geo:-53.9980310155285,-8.25347900390625;u=10" let uri = try XCTUnwrap(GeoURI(string: string)) - XCTAssertEqual(uri.latitude, -53.99803101552848) + XCTAssertEqual(uri.latitude, -53.9980310155285) XCTAssertEqual(uri.longitude, -8.25347900390625) XCTAssertEqual(uri.uncertainty, 10) XCTAssertEqual(uri.string, string) } func testValidMixedCoordinates() throws { - let string = "geo:53.99803101552848,-8.25347900390625;u=10.0" + let string = "geo:53.9980310155285,-8.25347900390625;u=10" let uri = try XCTUnwrap(GeoURI(string: string)) - XCTAssertEqual(uri.latitude, 53.99803101552848) + XCTAssertEqual(uri.latitude, 53.9980310155285) XCTAssertEqual(uri.longitude, -8.25347900390625) XCTAssertEqual(uri.uncertainty, 10) XCTAssertEqual(uri.string, string) } func testValidCoordinatesNoUncertainty() throws { - let string = "geo:53.99803101552848,-8.25347900390625" + let string = "geo:53.9980310155285,-8.25347900390625" let uri = try XCTUnwrap(GeoURI(string: string)) - XCTAssertEqual(uri.latitude, 53.99803101552848) + XCTAssertEqual(uri.latitude, 53.9980310155285) XCTAssertEqual(uri.longitude, -8.25347900390625) XCTAssertNil(uri.uncertainty) XCTAssertEqual(uri.string, string) @@ -60,7 +60,12 @@ final class GeoURITests: XCTestCase { XCTAssertEqual(uri.latitude, 53) XCTAssertEqual(uri.longitude, -8) XCTAssertEqual(uri.uncertainty, 35) - XCTAssertEqual(uri.string, "geo:53.0,-8.0;u=35.0") + XCTAssertEqual(uri.string, "geo:53,-8;u=35") + } + + func testFormattingExponentialNotation() throws { + let uri = GeoURI(latitude: 1e2, longitude: -1e-2, uncertainty: 1e-4) + XCTAssertEqual(uri.string, "geo:100,-0.01;u=0.0001") } func testInvalidURI1() { diff --git a/UnitTests/Sources/StaticLocationScreenViewModelTests.swift b/UnitTests/Sources/StaticLocationScreenViewModelTests.swift index ae70221a8..79aa52f8f 100644 --- a/UnitTests/Sources/StaticLocationScreenViewModelTests.swift +++ b/UnitTests/Sources/StaticLocationScreenViewModelTests.swift @@ -31,7 +31,7 @@ class StaticLocationScreenViewModelTests: XCTestCase { } override func setUpWithError() throws { - let viewModel = StaticLocationScreenViewModel() + let viewModel = StaticLocationScreenViewModel(interactionMode: .picker) viewModel.state.isPinDropSharing = false self.viewModel = viewModel }