Open map from timeline (#1199)

* 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
This commit is contained in:
Alfonso Grillo
2023-06-29 11:12:42 +02:00
committed by GitHub
parent 2cd03a9e7e
commit eb65619979
22 changed files with 489 additions and 150 deletions

View File

@@ -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 = "<group>"; };
42C64A14EE89928207E3B42B /* AnalyticsSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenModels.swift; sourceTree = "<group>"; };
42EEA67A6796BDC2761619C5 /* PaginationIndicatorRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIndicatorRoomTimelineView.swift; sourceTree = "<group>"; };
4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareToMapsAppActivity.swift; sourceTree = "<group>"; };
44D8C8431416EB8DFEC7E235 /* ApplicationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationTests.swift; sourceTree = "<group>"; };
450E04B2A976CC4C8CC1807C /* EmoteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItem.swift; sourceTree = "<group>"; };
4549FCB53F43DB0B278374BC /* TemplateScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreen.swift; sourceTree = "<group>"; };
@@ -1109,6 +1112,7 @@
8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = "<group>"; };
8E1BBA73B611EDEEA6E20E05 /* InvitesScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenModels.swift; sourceTree = "<group>"; };
8EC57A32ABC80D774CC663DB /* SettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenUITests.swift; sourceTree = "<group>"; };
8F21ED7205048668BEB44A38 /* AppActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppActivityView.swift; sourceTree = "<group>"; };
8F61A0DD8243B395499C99A2 /* InvitesScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenUITests.swift; sourceTree = "<group>"; };
8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = "<group>"; };
8FC26871038FB0E4AAE22605 /* apple_emojis_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = apple_emojis_data.json; sourceTree = "<group>"; };
@@ -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 */,

View File

@@ -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!";

View File

@@ -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

View File

@@ -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!

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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
}
}

View File

@@ -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<LocationSharingViewError>?
var showShareSheet = false
}
enum StaticLocationScreenViewAction {

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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))
}
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -41,7 +41,6 @@ enum RoomProxyError: Error {
case failedSettingRoomTopic
case failedRemovingAvatar
case failedUploadingAvatar
case failedSendingLocation
}
@MainActor

View File

@@ -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
}()
}

View File

@@ -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]()

View File

@@ -26,6 +26,7 @@ enum RoomTimelineControllerCallback {
enum RoomTimelineControllerAction {
case displayMediaFile(file: MediaFileHandleProxy, title: String?)
case displayLocation(body: String, geoURI: GeoURI)
case none
}

View File

@@ -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() {

View File

@@ -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
}