mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ enum RoomProxyError: Error {
|
||||
case failedSettingRoomTopic
|
||||
case failedRemovingAvatar
|
||||
case failedUploadingAvatar
|
||||
case failedSendingLocation
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
|
||||
+35
-24
@@ -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]()
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ enum RoomTimelineControllerCallback {
|
||||
|
||||
enum RoomTimelineControllerAction {
|
||||
case displayMediaFile(file: MediaFileHandleProxy, title: String?)
|
||||
case displayLocation(body: String, geoURI: GeoURI)
|
||||
case none
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user