Send pin-drop location (#1179)

* add location sharing action in room, and open location sharing screen

* add pin location sharing

* fix asset, add tests for location viewModel, add send location request

* fix map zoom level, fix assets for location

* add feature flag for location sharing

* hide attribution button
This commit is contained in:
Flescio
2023-06-28 11:39:38 +02:00
committed by GitHub
parent e25ca2e120
commit 102e6a64fa
29 changed files with 548 additions and 42 deletions
@@ -30,6 +30,7 @@ final class AppSettings {
case userSuggestionsEnabled
case readReceiptsEnabled
case locationEventsEnabled
case shareLocationEnabled
}
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
@@ -194,4 +195,7 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.locationEventsEnabled, defaultValue: false, storageType: .userDefaults(store))
var locationEventsEnabled
@UserPreference(key: UserDefaultsKeys.shareLocationEnabled, defaultValue: false, storageType: .userDefaults(store))
var shareLocationEnabled
}
@@ -150,7 +150,10 @@ 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)):
return .room(roomID: roomID)
default:
return nil
}
@@ -218,7 +221,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
presentMessageForwarding(for: eventID)
case (.messageForwarding, .dismissMessageForwarding, .room):
break
case (.room, .presentLocationPicker, .locationPicker):
presentLocationPicker()
case (.locationPicker, .dismissLocationPicker, .room):
break
default:
fatalError("Unknown transition: \(context)")
}
@@ -312,6 +318,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: url))
case .presentEmojiPicker(let itemID):
stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID))
case .presentLocationPicker:
stateMachine.tryEvent(.presentLocationPicker)
case .presentRoomMemberDetails(member: let member):
stateMachine.tryEvent(.presentRoomMemberDetails(member: .init(value: member)))
case .presentMessageForwarding(let itemID):
@@ -519,6 +527,33 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func presentLocationPicker() {
let locationPickerNavigationStackCoordinator = NavigationStackCoordinator()
let params = StaticLocationScreenCoordinatorParameters()
let coordinator = StaticLocationScreenCoordinator(parameters: params)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .selectedLocation(let geoURI):
Task {
_ = await self.roomProxy?.sendLocation(body: geoURI.bodyMessage, geoURI: geoURI)
self.navigationSplitCoordinator.setSheetCoordinator(nil)
}
case .close:
self.navigationSplitCoordinator.setSheetCoordinator(nil)
}
}
.store(in: &cancellables)
locationPickerNavigationStackCoordinator.setRootCoordinator(coordinator)
navigationStackCoordinator.setSheetCoordinator(locationPickerNavigationStackCoordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissLocationPicker)
}
}
private func presentRoomMemberDetails(member: RoomMemberProxyProtocol) {
let params = RoomMemberDetailsScreenCoordinatorParameters(roomMemberProxy: member, mediaProvider: userSession.mediaProvider)
let coordinator = RoomMemberDetailsScreenCoordinator(parameters: params)
@@ -611,6 +646,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 roomMemberDetails(roomID: String, member: HashableRoomMemberWrapper)
case messageForwarding(roomID: String, itemID: String)
}
@@ -642,6 +678,9 @@ private extension RoomFlowCoordinator {
case presentEmojiPicker(itemID: String)
case dismissEmojiPicker
case presentLocationPicker
case dismissLocationPicker
case presentRoomMemberDetails(member: HashableRoomMemberWrapper)
case dismissRoomMemberDetails
@@ -649,3 +688,9 @@ private extension RoomFlowCoordinator {
case dismissMessageForwarding
}
}
private extension GeoURI {
var bodyMessage: String {
String(format: "Location was shared at %@ as of %@", string, Date().description)
}
}
+2 -1
View File
@@ -29,6 +29,8 @@ internal enum Asset {
internal static let backgroundColor = ColorAsset(name: "colors/background-color")
}
internal enum Images {
internal static let locationMarker = ImageAsset(name: "images/location-marker")
internal static let locationPin = ImageAsset(name: "images/location-pin")
internal static let appLogo = ImageAsset(name: "images/app-logo")
internal static let serverSelectionIcon = ImageAsset(name: "images/server-selection-icon")
internal static let closeCircle = ImageAsset(name: "images/close-circle")
@@ -37,7 +39,6 @@ internal enum Asset {
internal static let encryptionWarning = ImageAsset(name: "images/encryption-warning")
internal static let launchBackground = ImageAsset(name: "images/launch-background")
internal static let launchLogo = ImageAsset(name: "images/launch-logo")
internal static let locationPin = ImageAsset(name: "images/location-pin")
internal static let timelineComposerSendMessage = ImageAsset(name: "images/timeline-composer-send-message")
}
}
@@ -23,6 +23,7 @@ struct MapLibreMapView: UIViewRepresentable {
private enum Constants {
static let mapZoomLevel = 15.0
static let mapZoomLevelWithoutPermission = 5.0
}
// MARK: - Properties
@@ -36,12 +37,22 @@ struct MapLibreMapView: UIViewRepresentable {
/// Bind view errors if any
let error: Binding<MapLibreError?>
/// Coordinate of the center of the map
@Binding var mapCenterCoordinate: CLLocationCoordinate2D?
/// Called when the user pan on the map
var userDidPan: (() -> Void)?
// MARK: - UIViewRepresentable
func makeUIView(context: Context) -> MGLMapView {
let mapView = makeMapView()
mapView.delegate = context.coordinator
let panGesture = UIPanGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.didPan))
panGesture.delegate = context.coordinator
mapView.addGestureRecognizer(panGesture)
mapView.zoomLevel = Constants.mapZoomLevelWithoutPermission
return mapView
}
@@ -65,12 +76,9 @@ struct MapLibreMapView: UIViewRepresentable {
private func makeMapView() -> MGLMapView {
let mapView = MGLMapView(frame: .zero, styleURL: colorScheme == .dark ? builder.dynamicMapURL(for: .dark) : builder.dynamicMapURL(for: .light))
mapView.logoView.isHidden = true
mapView.attributionButton.isHidden = true
mapView.zoomLevel = Constants.mapZoomLevel
showUserLocation(in: mapView)
mapView.attributionButton.isHidden = true
return mapView
}
@@ -134,7 +142,9 @@ extension MapLibreMapView {
}
}
func mapView(_ mapView: MGLMapView, regionDidChangeAnimated animated: Bool) { }
func mapView(_ mapView: MGLMapView, regionDidChangeAnimated animated: Bool) {
mapLibreView.mapCenterCoordinate = mapView.centerCoordinate
}
// MARK: Callout
@@ -147,6 +157,11 @@ extension MapLibreMapView {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
gestureRecognizer is UIPanGestureRecognizer
}
@objc
func didPan() {
mapLibreView.userDidPan?()
}
}
}
@@ -16,9 +16,9 @@
import SwiftUI
struct LocationPinView: View {
struct LocationMarkerView: View {
var body: some View {
Image(Asset.Images.locationPin.name)
Image(Asset.Images.locationMarker.name)
.alignmentGuide(VerticalAlignment.center) { dimensions in
dimensions[.bottom]
}
@@ -26,12 +26,12 @@ struct LocationPinView: View {
}
}
struct LocationPinView_Previews: PreviewProvider {
struct LocationMarkerView_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 30) {
LocationPinView()
LocationMarkerView()
LocationPinView()
LocationMarkerView()
.colorScheme(.dark)
}
}
@@ -14,6 +14,7 @@
// limitations under the License.
//
import CoreLocation
import Foundation
enum LocationSharingViewError: Error, Hashable {
@@ -21,13 +22,23 @@ enum LocationSharingViewError: Error, Hashable {
case mapError(MapLibreError)
}
enum StaticLocationScreenViewModelAction { }
enum StaticLocationScreenViewModelAction {
case close
case sendLocation(GeoURI)
}
struct StaticLocationScreenViewState: BindableState {
/// 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()
}
struct StaticLocationScreenBindings {
var mapCenterLocation: CLLocationCoordinate2D?
/// Information describing the currently displayed alert.
var mapError: MapLibreError? {
get {
@@ -45,4 +56,8 @@ struct StaticLocationScreenBindings {
var alertInfo: AlertInfo<LocationSharingViewError>?
}
enum StaticLocationScreenViewAction { }
enum StaticLocationScreenViewAction {
case close
case selectLocation
case userDidPan
}
@@ -19,12 +19,22 @@ import SwiftUI
struct StaticLocationScreenCoordinatorParameters { }
enum StaticLocationScreenCoordinatorAction { }
enum StaticLocationScreenCoordinatorAction {
case close
case selectedLocation(GeoURI)
}
final class StaticLocationScreenCoordinator: CoordinatorProtocol {
let parameters: StaticLocationScreenCoordinatorParameters
let viewModel: StaticLocationScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<StaticLocationScreenCoordinatorAction, Never> = .init()
private var cancellables: Set<AnyCancellable> = .init()
var actions: AnyPublisher<StaticLocationScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: StaticLocationScreenCoordinatorParameters) {
self.parameters = parameters
@@ -33,6 +43,19 @@ final class StaticLocationScreenCoordinator: CoordinatorProtocol {
// MARK: - Public
func start() {
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .close:
actionsSubject.send(.close)
case .sendLocation(let geoURI):
actionsSubject.send(.selectedLocation(geoURI))
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(StaticLocationScreen(context: viewModel.context))
}
@@ -17,7 +17,7 @@
import Combine
import Foundation
typealias StaticLocationScreenViewModelType = StateStoreViewModel<StaticLocationScreenViewState, StaticLocationScreenViewModelAction>
typealias StaticLocationScreenViewModelType = StateStoreViewModel<StaticLocationScreenViewState, StaticLocationScreenViewAction>
class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLocationScreenViewModelProtocol {
private let actionsSubject: PassthroughSubject<StaticLocationScreenViewModelAction, Never> = .init()
@@ -29,4 +29,17 @@ class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLo
init() {
super.init(initialViewState: StaticLocationScreenViewState())
}
override func process(viewAction: StaticLocationScreenViewAction) {
switch viewAction {
case .close:
actionsSubject.send(.close)
case .selectLocation:
guard let coordinate = state.bindings.mapCenterLocation else { return }
actionsSubject.send(.sendLocation(.init(coordinate: coordinate)))
case .userDidPan:
state.showsUserLocationMode = .hide
state.isPinDropSharing = true
}
}
}
@@ -22,18 +22,62 @@ struct StaticLocationScreen: View {
private let builder = MapTilerStyleBuilder(appSettings: ServiceLocator.shared.settings)
var body: some View {
NavigationView {
mapView
.ignoresSafeArea(.all, edges: [.bottom])
.navigationBarTitleDisplayMode(.inline)
.alert(item: $context.alertInfo)
mapView
.ignoresSafeArea(.all, edges: .horizontal)
.navigationTitle(L10n.screenShareLocationTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.alert(item: $context.alertInfo)
}
private var mapView: some View {
ZStack(alignment: .center) {
MapLibreMapView(builder: builder,
showsUserLocationMode: .hide,
error: $context.mapError,
mapCenterCoordinate: $context.mapCenterLocation,
userDidPan: {
context.send(viewAction: .userDidPan)
})
if context.viewState.isPinDropSharing {
LocationMarkerView()
}
}
}
var mapView: MapLibreMapView {
MapLibreMapView(builder: builder,
showsUserLocationMode: .follow,
error: $context.mapError)
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
closeButton
}
ToolbarItemGroup(placement: .bottomBar) {
shareLocationButton
Spacer()
}
}
@ScaledMetric private var shareMarkerSize: CGFloat = 28
private var shareLocationButton: some View {
Button {
context.send(viewAction: .selectLocation)
} label: {
HStack(spacing: 8) {
Image(asset: Asset.Images.locationMarker)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: shareMarkerSize, height: shareMarkerSize)
Text(context.viewState.isPinDropSharing ? L10n.screenShareThisLocationAction : L10n.screenShareMyLocationAction)
}
}
}
private var closeButton: some View {
Button(L10n.actionCancel, action: close)
}
private func close() {
context.send(viewAction: .close)
}
}
@@ -30,6 +30,7 @@ enum RoomScreenCoordinatorAction {
case presentMediaUploadPicker(MediaPickerScreenSource)
case presentMediaUploadPreviewScreen(URL)
case presentRoomDetails
case presentLocationPicker
case presentEmojiPicker(itemID: String)
case presentRoomMemberDetails(member: RoomMemberProxyProtocol)
case presentMessageForwarding(itemID: String)
@@ -78,6 +79,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentMediaUploadPicker(.photoLibrary))
case .displayDocumentPicker:
actionsSubject.send(.presentMediaUploadPicker(.documents))
case .displayLocationPicker:
actionsSubject.send(.presentLocationPicker)
case .displayMediaUploadPreviewScreen(let url):
actionsSubject.send(.presentMediaUploadPreviewScreen(url))
case .displayRoomMemberDetails(let member):
@@ -26,6 +26,7 @@ enum RoomScreenViewModelAction {
case displayCameraPicker
case displayMediaPicker
case displayDocumentPicker
case displayLocationPicker
case displayMediaUploadPreviewScreen(url: URL)
case displayRoomMemberDetails(member: RoomMemberProxyProtocol)
case displayMessageForwarding(itemID: String)
@@ -68,6 +69,7 @@ enum RoomScreenViewAction {
case displayCameraPicker
case displayMediaPicker
case displayDocumentPicker
case displayLocationPicker
case handlePasteOrDrop(provider: NSItemProvider)
case tappedOnUser(userID: String)
@@ -109,6 +109,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
callback?(.displayMediaPicker)
case .displayDocumentPicker:
callback?(.displayDocumentPicker)
case .displayLocationPicker:
callback?(.displayLocationPicker)
case .handlePasteOrDrop(let provider):
handlePasteOrDrop(provider)
case .tappedOnUser(userID: let userID):
@@ -66,7 +66,7 @@ struct TimelineReplyView: View {
ReplyView(sender: sender,
plainBody: L10n.commonSharedLocation,
formattedBody: nil,
icon: .init(kind: .icon(Asset.Images.locationPin.name), cornerRadii: iconCornerRadii))
icon: .init(kind: .icon(Asset.Images.locationMarker.name), cornerRadii: iconCornerRadii))
}
default:
LoadingReplyView()
@@ -37,21 +37,30 @@ struct RoomAttachmentPicker: View {
context.showAttachmentPopover = false
context.send(viewAction: .displayMediaPicker)
} label: {
PickerLabel(title: L10n.screenRoomAttachmentSourceGallery, systemImageName: "photo.fill")
PickerLabel(title: L10n.screenRoomAttachmentSourceGallery, icon: Image(systemName: "photo.fill"))
}
Button {
context.showAttachmentPopover = false
context.send(viewAction: .displayDocumentPicker)
} label: {
PickerLabel(title: L10n.screenRoomAttachmentSourceFiles, systemImageName: "paperclip")
PickerLabel(title: L10n.screenRoomAttachmentSourceFiles, icon: Image(systemName: "paperclip"))
}
Button {
context.showAttachmentPopover = false
context.send(viewAction: .displayCameraPicker)
} label: {
PickerLabel(title: L10n.screenRoomAttachmentSourceCamera, systemImageName: "camera.fill")
PickerLabel(title: L10n.screenRoomAttachmentSourceCamera, icon: Image(systemName: "camera.fill"))
}
if ServiceLocator.shared.settings.shareLocationEnabled {
Button {
context.showAttachmentPopover = false
context.send(viewAction: .displayLocationPicker)
} label: {
PickerLabel(title: L10n.screenRoomAttachmentSourceLocation, icon: Image(asset: Asset.Images.locationPin))
}
}
}
.padding(.top, isPresented ? 20 : 0)
@@ -73,14 +82,23 @@ struct RoomAttachmentPicker: View {
private struct PickerLabel: View {
let title: String
let systemImageName: String
let icon: Image
init(title: String, icon: Image) {
self.title = title
self.icon = icon
}
var body: some View {
Label(title, systemImage: systemImageName)
.labelStyle(FixedIconSizeLabelStyle())
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
Label {
Text(title)
} icon: {
icon
}
.labelStyle(FixedIconSizeLabelStyle())
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
}
}
}
@@ -25,7 +25,7 @@ struct LocationRoomTimelineView: View {
if let geoURI = timelineItem.content.geoURI {
let mapSize: CGSize = .init(width: 292, height: 188)
MapLibreStaticMapView(geoURI: geoURI, size: mapSize) {
LocationPinView()
LocationMarkerView()
}
.frame(width: mapSize.width, height: mapSize.height)
} else {
@@ -30,6 +30,7 @@ struct DeveloperOptionsScreenViewStateBindings {
var readReceiptsEnabled: Bool
var isEncryptionSyncEnabled: Bool
var locationEventsEnabled: Bool
var shareLocationEnabled: Bool
}
enum DeveloperOptionsScreenViewAction {
@@ -38,5 +39,6 @@ enum DeveloperOptionsScreenViewAction {
case changedReadReceiptsEnabled
case changedIsEncryptionSyncEnabled
case changedLocationEventsEnabled
case changedShareLocationEnabled
case clearCache
}
@@ -30,7 +30,8 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
userSuggestionsEnabled: appSettings.userSuggestionsEnabled,
readReceiptsEnabled: appSettings.readReceiptsEnabled,
isEncryptionSyncEnabled: appSettings.isEncryptionSyncEnabled,
locationEventsEnabled: appSettings.locationEventsEnabled)
locationEventsEnabled: appSettings.locationEventsEnabled,
shareLocationEnabled: appSettings.shareLocationEnabled)
let state = DeveloperOptionsScreenViewState(bindings: bindings)
super.init(initialViewState: state)
@@ -52,6 +53,8 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
appSettings.isEncryptionSyncEnabled = state.bindings.isEncryptionSyncEnabled
case .changedLocationEventsEnabled:
appSettings.locationEventsEnabled = state.bindings.locationEventsEnabled
case .changedShareLocationEnabled:
appSettings.shareLocationEnabled = state.bindings.shareLocationEnabled
case .clearCache:
callback?(.clearCache)
}
@@ -59,6 +59,13 @@ struct DeveloperOptionsScreen: View {
.onChange(of: context.locationEventsEnabled) { _ in
context.send(viewAction: .changedLocationEventsEnabled)
}
Toggle(isOn: $context.shareLocationEnabled) {
Text("Show share location action")
}
.onChange(of: context.shareLocationEnabled) { _ in
context.send(viewAction: .changedShareLocationEnabled)
}
}
Section {
@@ -14,6 +14,7 @@
// limitations under the License.
//
import CoreLocation
import Foundation
/// A structure that parses a geo URI (i.e. geo:53.99803101552848,-8.25347900390625;u=10) and constructs their constituent parts.
@@ -71,3 +72,9 @@ private typealias RegexGeoURI = Regex<(Substring, latitude: Substring, longitude
private extension RegexGeoURI {
static let standard: Self = /geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?/
}
extension GeoURI {
init(coordinate: CLLocationCoordinate2D) {
self.init(latitude: coordinate.latitude, longitude: coordinate.longitude)
}
}