diff --git a/ElementX.xcodeproj/xcshareddata/xcschemes/ElementX.xcscheme b/ElementX.xcodeproj/xcshareddata/xcschemes/ElementX.xcscheme index af399e45d1..81b1b3191f 100644 --- a/ElementX.xcodeproj/xcshareddata/xcschemes/ElementX.xcscheme +++ b/ElementX.xcodeproj/xcshareddata/xcschemes/ElementX.xcscheme @@ -4,8 +4,7 @@ version = "1.7"> + buildImplicitDependencies = "YES"> - - - - - - - - + + + + - - - - diff --git a/ElementX/Resources/Assets.xcassets/images/location/location-pointer-full.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/location/location-pointer-full.imageset/Contents.json new file mode 100644 index 0000000000..11cb61a995 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/location/location-pointer-full.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "location-pointer-full.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "location-pointer-full-dark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/location/location-pointer-full.imageset/location-pointer-full-dark.pdf b/ElementX/Resources/Assets.xcassets/images/location/location-pointer-full.imageset/location-pointer-full-dark.pdf new file mode 100644 index 0000000000..7715aee977 Binary files /dev/null and b/ElementX/Resources/Assets.xcassets/images/location/location-pointer-full.imageset/location-pointer-full-dark.pdf differ diff --git a/ElementX/Resources/Assets.xcassets/images/location/location-pointer-full.imageset/location-pointer-full.pdf b/ElementX/Resources/Assets.xcassets/images/location/location-pointer-full.imageset/location-pointer-full.pdf new file mode 100644 index 0000000000..76a7e9159d Binary files /dev/null and b/ElementX/Resources/Assets.xcassets/images/location/location-pointer-full.imageset/location-pointer-full.pdf differ diff --git a/ElementX/Resources/Assets.xcassets/images/location/location-pointer.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/location/location-pointer.imageset/Contents.json new file mode 100644 index 0000000000..10d9bad0c8 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/location/location-pointer.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "location-pointer.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "location-pointer-dark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/location/location-pointer.imageset/location-pointer-dark.pdf b/ElementX/Resources/Assets.xcassets/images/location/location-pointer.imageset/location-pointer-dark.pdf new file mode 100644 index 0000000000..6190511bad Binary files /dev/null and b/ElementX/Resources/Assets.xcassets/images/location/location-pointer.imageset/location-pointer-dark.pdf differ diff --git a/ElementX/Resources/Assets.xcassets/images/location/location-pointer.imageset/location-pointer.pdf b/ElementX/Resources/Assets.xcassets/images/location/location-pointer.imageset/location-pointer.pdf new file mode 100644 index 0000000000..9613d1ff87 Binary files /dev/null and b/ElementX/Resources/Assets.xcassets/images/location/location-pointer.imageset/location-pointer.pdf differ diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 3a2c194198..c692f2a1a2 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -139,10 +139,10 @@ "emoji_picker_category_places" = "Travel & Places"; "emoji_picker_category_symbols" = "Symbols"; "error_failed_creating_the_permalink" = "Failed creating the permalink"; -"error_failed_loading_map" = "Element could not load the map. Please try again later."; +"error_failed_loading_map" = "%1$@ could not load the map. Please try again later."; "error_failed_loading_messages" = "Failed loading messages"; -"error_failed_locating_user" = "Element could not access your location. Please try again later."; -"error_missing_location_auth" = "Element does not have permission to access your location. You can enable access in Settings > Location"; +"error_failed_locating_user" = "%1$@ could not access your location. Please try again later."; +"error_missing_location_auth" = "%1$@ does not have permission to access your location. You can enable access in Settings > Location"; "error_no_compatible_app_found" = "No compatible app was found to handle this action."; "error_some_messages_have_not_been_sent" = "Some messages have not been sent"; "error_unknown" = "Sorry, an error occurred"; @@ -308,6 +308,8 @@ "screen_room_reactions_show_more" = "Show more"; "screen_room_retry_send_menu_send_again_action" = "Send again"; "screen_room_retry_send_menu_title" = "Your message failed to send"; +"screen_room_timeline_add_reaction" = "Add emoji"; +"screen_room_timeline_less_reactions" = "Show less"; "screen_roomlist_a11y_create_message" = "Create a new conversation or room"; "screen_roomlist_main_space_title" = "All Chats"; "screen_server_confirmation_change_server" = "Change account provider"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict index e6cefe155c..f2e3f0a516 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict @@ -146,5 +146,19 @@ %1$d people + screen_room_timeline_more_reactions + + NSStringLocalizedFormatKey + %#@COUNT@ + COUNT + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + other + %1$d more + + \ No newline at end of file diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index b1e40e88c1..591e6cf71a 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -515,7 +515,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { _ = await self.roomProxy?.sendLocation(body: geoURI.bodyMessage, geoURI: geoURI, description: nil, - zoomLevel: nil, + zoomLevel: 15, assetType: isUserLocation ? .sender : .pin) self.navigationSplitCoordinator.setSheetCoordinator(nil) } diff --git a/ElementX/Sources/Generated/Assets.swift b/ElementX/Sources/Generated/Assets.swift index b802fa44f2..6dfa4250e5 100644 --- a/ElementX/Sources/Generated/Assets.swift +++ b/ElementX/Sources/Generated/Assets.swift @@ -39,6 +39,8 @@ internal enum Asset { internal static let launchLogo = ImageAsset(name: "images/launch-logo") internal static let locationMarker = ImageAsset(name: "images/location-marker") internal static let locationPin = ImageAsset(name: "images/location-pin") + internal static let locationPointerFull = ImageAsset(name: "images/location-pointer-full") + internal static let locationPointer = ImageAsset(name: "images/location-pointer") internal static let timelineComposerSendMessage = ImageAsset(name: "images/timeline-composer-send-message") internal static let waitingGradient = ImageAsset(name: "images/waiting-gradient") } diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index b79a15afd0..b7ce519be7 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -308,14 +308,20 @@ public enum L10n { public static var emojiPickerCategorySymbols: String { return L10n.tr("Localizable", "emoji_picker_category_symbols") } /// Failed creating the permalink public static var errorFailedCreatingThePermalink: String { return L10n.tr("Localizable", "error_failed_creating_the_permalink") } - /// Element could not load the map. Please try again later. - public static var errorFailedLoadingMap: String { return L10n.tr("Localizable", "error_failed_loading_map") } + /// %1$@ could not load the map. Please try again later. + public static func errorFailedLoadingMap(_ p1: Any) -> String { + return L10n.tr("Localizable", "error_failed_loading_map", String(describing: p1)) + } /// Failed loading messages public static var errorFailedLoadingMessages: String { return L10n.tr("Localizable", "error_failed_loading_messages") } - /// Element could not access your location. Please try again later. - public static var errorFailedLocatingUser: String { return L10n.tr("Localizable", "error_failed_locating_user") } - /// Element does not have permission to access your location. You can enable access in Settings > Location - public static var errorMissingLocationAuth: String { return L10n.tr("Localizable", "error_missing_location_auth") } + /// %1$@ could not access your location. Please try again later. + public static func errorFailedLocatingUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "error_failed_locating_user", String(describing: p1)) + } + /// %1$@ does not have permission to access your location. You can enable access in Settings > Location + public static func errorMissingLocationAuth(_ p1: Any) -> String { + return L10n.tr("Localizable", "error_missing_location_auth", String(describing: p1)) + } /// No compatible app was found to handle this action. public static var errorNoCompatibleAppFound: String { return L10n.tr("Localizable", "error_no_compatible_app_found") } /// Some messages have not been sent @@ -780,6 +786,14 @@ public enum L10n { public static var screenRoomRetrySendMenuSendAgainAction: String { return L10n.tr("Localizable", "screen_room_retry_send_menu_send_again_action") } /// Your message failed to send public static var screenRoomRetrySendMenuTitle: String { return L10n.tr("Localizable", "screen_room_retry_send_menu_title") } + /// Add emoji + public static var screenRoomTimelineAddReaction: String { return L10n.tr("Localizable", "screen_room_timeline_add_reaction") } + /// Show less + public static var screenRoomTimelineLessReactions: String { return L10n.tr("Localizable", "screen_room_timeline_less_reactions") } + /// Plural format key: "%#@COUNT@" + public static func screenRoomTimelineMoreReactions(_ p1: Int) -> String { + return L10n.tr("Localizable", "screen_room_timeline_more_reactions", p1) + } /// Create a new conversation or room public static var screenRoomlistA11yCreateMessage: String { return L10n.tr("Localizable", "screen_roomlist_a11y_create_message") } /// All Chats diff --git a/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift b/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift index ee0e2947ca..41a039f8b7 100644 --- a/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift +++ b/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift @@ -20,15 +20,20 @@ import SwiftUI struct MapLibreMapView: UIViewRepresentable { struct Options { - /// The initial zoom level + /// the final zoom level used when the first user location emit let zoomLevel: Double + /// The initial zoom level used when the map it firstly loaded and the user location is not yet available, in case of annotations this property is not being used + let initialZoomLevel: Double + /// The initial map center - let mapCenter: CLLocationCoordinate2D? + let mapCenter: CLLocationCoordinate2D + /// Map annotations let annotations: [LocationAnnotation] - init(zoomLevel: Double, mapCenter: CLLocationCoordinate2D? = nil, annotations: [LocationAnnotation] = []) { + init(zoomLevel: Double, initialZoomLevel: Double, mapCenter: CLLocationCoordinate2D, annotations: [LocationAnnotation] = []) { self.zoomLevel = zoomLevel + self.initialZoomLevel = initialZoomLevel self.mapCenter = mapCenter self.annotations = annotations } @@ -43,14 +48,16 @@ struct MapLibreMapView: UIViewRepresentable { 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 + @Binding var showsUserLocationMode: ShowUserLocationMode /// Bind view errors if any - let error: Binding + @Binding var error: MapLibreError? /// Coordinate of the center of the map @Binding var mapCenterCoordinate: CLLocationCoordinate2D? + @Binding var isLocationAuthorized: Bool? + /// Called when the user pan on the map var userDidPan: (() -> Void)? @@ -59,23 +66,12 @@ struct MapLibreMapView: 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) 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) - } else { - mapView.styleURL = builder.dynamicMapURL(for: .light) - } - + mapView.styleURL = builder.dynamicMapURL(for: .init(colorScheme)) showUserLocation(in: mapView) } @@ -86,32 +82,36 @@ 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 - } + mapView.addAnnotations(options.annotations) + mapView.zoomLevel = options.annotations.isEmpty ? options.initialZoomLevel : options.zoomLevel + mapView.centerCoordinate = options.mapCenter } private func makeMapView() -> MGLMapView { let mapView = MGLMapView(frame: .zero, styleURL: colorScheme == .dark ? builder.dynamicMapURL(for: .dark) : builder.dynamicMapURL(for: .light)) - - showUserLocation(in: mapView) - mapView.attributionButton.isHidden = true - + mapView.logoViewPosition = .topLeft + mapView.attributionButtonPosition = .topLeft + mapView.attributionButtonMargins = .init(x: mapView.logoView.frame.maxX + 8, y: mapView.logoView.center.y / 2) return mapView } private func showUserLocation(in mapView: MGLMapView) { - switch showsUserLocationMode { - case .showAndFollow: - mapView.showsUserLocation = true + switch (showsUserLocationMode, options.annotations) { + case (.showAndFollow, _): mapView.userTrackingMode = .follow - case .show: + case (.show, let annotations) where !annotations.isEmpty: + /** in the show mode, if there are annotations, we check the authorizationStatus, + if it's not determined, we wont prompt the user with a request for permissions, + because he should be able to see the annotations without sharing his location informations + **/ + guard mapView.locationManager.authorizationStatus != .notDetermined else { return } + fallthrough + case (.show, _): mapView.showsUserLocation = true - mapView.userTrackingMode = .none - case .hide: + mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil) + case (.hide, _): mapView.showsUserLocation = false - mapView.userTrackingMode = .none + mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil) } } } @@ -119,11 +119,13 @@ struct MapLibreMapView: UIViewRepresentable { // MARK: - Coordinator extension MapLibreMapView { - class Coordinator: NSObject, MGLMapViewDelegate, UIGestureRecognizerDelegate { + class Coordinator: NSObject, MGLMapViewDelegate { // MARK: - Properties var mapLibreView: MapLibreMapView + private var previousUserLocation: MGLUserLocation? + // MARK: - Setup init(_ mapLibreView: MapLibreMapView) { @@ -140,20 +142,30 @@ extension MapLibreMapView { } func mapViewDidFailLoadingMap(_ mapView: MGLMapView, withError error: Error) { - mapLibreView.error.wrappedValue = .failedLoadingMap + mapLibreView.error = .failedLoadingMap } - func mapView(_ mapView: MGLMapView, didUpdate userLocation: MGLUserLocation?) { } + func mapView(_ mapView: MGLMapView, didUpdate userLocation: MGLUserLocation?) { + guard let userLocation else { return } + + if previousUserLocation == nil, mapLibreView.options.annotations.isEmpty { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + mapView.setCenter(userLocation.coordinate, zoomLevel: self.mapLibreView.options.zoomLevel, animated: true) + } + } + + previousUserLocation = userLocation + } func mapView(_ mapView: MGLMapView, didChangeLocationManagerAuthorization manager: MGLLocationManager) { - guard mapView.showsUserLocation else { - return - } - switch manager.authorizationStatus { case .denied, .restricted: - mapLibreView.error.wrappedValue = .invalidLocationAuthorization - default: + mapLibreView.isLocationAuthorized = false + case .authorizedAlways, .authorizedWhenInUse: + mapLibreView.isLocationAuthorized = true + case .notDetermined: + mapLibreView.isLocationAuthorized = nil + @unknown default: break } } @@ -171,15 +183,25 @@ extension MapLibreMapView { false } - // MARK: UIGestureRecognizer - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - gestureRecognizer is UIPanGestureRecognizer - } - - @objc - func didPan() { - mapLibreView.userDidPan?() + func mapView(_ mapView: MGLMapView, shouldChangeFrom oldCamera: MGLMapCamera, to newCamera: MGLMapCamera, reason: MGLCameraChangeReason) -> Bool { + // we send the userDidPan event only for the reasons that actually will change the map center, and not zoom only / rotations only events. + switch reason { + case .gesturePan, + .gesturePinch, + .gestureRotate: + mapLibreView.userDidPan?() + case .gestureOneFingerZoom, + .gestureTilt, + .gestureZoomIn, + .gestureZoomOut, + .programmatic, + .resetNorth, + .transitionCancelled: + break + default: + break + } + return true } } } @@ -194,3 +216,16 @@ private extension MGLMapView { removeAnnotations(annotations) } } + +private extension MapTilerStyle { + init(_ colorScheme: ColorScheme) { + switch colorScheme { + case .light: + self = .light + case .dark: + self = .dark + @unknown default: + fatalError() + } + } +} diff --git a/ElementX/Sources/Other/MapLibre/MapLibreModels.swift b/ElementX/Sources/Other/MapLibre/MapLibreModels.swift index 538af291a1..16f76a25ab 100644 --- a/ElementX/Sources/Other/MapLibre/MapLibreModels.swift +++ b/ElementX/Sources/Other/MapLibre/MapLibreModels.swift @@ -36,7 +36,6 @@ enum MapTilerStyle { enum MapLibreError: Error { case failedLoadingMap case failedLocatingUser - case invalidLocationAuthorization } enum MapTilerAttributionPlacement: String { diff --git a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift index 609347e5b1..35dd1291a6 100644 --- a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift +++ b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift @@ -18,12 +18,13 @@ import CoreLocation import Foundation enum LocationSharingViewError: Error, Hashable { - case failedSharingLocation + case missingAuthorization case mapError(MapLibreError) } enum StaticLocationScreenViewModelAction { case close + case openSystemSettings case sendLocation(GeoURI, isUserLocation: Bool) } @@ -33,47 +34,36 @@ enum StaticLocationInteractionMode: Hashable { } struct StaticLocationScreenViewState: BindableState { - init(interactionMode: StaticLocationInteractionMode, isSharingUserLocation: Bool = false, showsUserLocationMode: ShowUserLocationMode = .hide) { + init(interactionMode: StaticLocationInteractionMode) { self.interactionMode = interactionMode - self.isSharingUserLocation = isSharingUserLocation - self.showsUserLocationMode = showsUserLocationMode - switch interactionMode { case .picker: - bindings = .init() - case .viewOnly(let geoURI, _): - bindings = .init(mapCenterLocation: .init(latitude: geoURI.latitude, longitude: geoURI.longitude)) + bindings.showsUserLocationMode = .showAndFollow + case .viewOnly: + bindings.showsUserLocationMode = .show } } let interactionMode: StaticLocationInteractionMode /// Indicates whether the user is sharing his current location - var isSharingUserLocation: Bool - /// Behavior mode of the current user's location, can be hidden, only shown and shown following the user - var showsUserLocationMode: ShowUserLocationMode - - var bindings = StaticLocationScreenBindings() - - var showBottomToolbar: Bool { - interactionMode == .picker + var isSharingUserLocation: Bool { + bindings.isLocationAuthorized == true && bindings.showsUserLocationMode == .showAndFollow } - - var mapAnnotationCoordinate: CLLocationCoordinate2D? { + + var bindings = StaticLocationScreenBindings(showsUserLocationMode: .hide) + + var initialMapCenter: CLLocationCoordinate2D { switch interactionMode { case .picker: - return nil + // middle point in Europe, to be used if the users location is not yet known + return .init(latitude: 49.843, longitude: 9.902056) case .viewOnly(let geoURI, _): return .init(latitude: geoURI.latitude, longitude: geoURI.longitude) } } var isLocationPickerMode: Bool { - switch interactionMode { - case .picker: - return true - case .viewOnly: - return false - } + interactionMode == .picker } var navigationTitle: String { @@ -95,9 +85,13 @@ struct StaticLocationScreenViewState: BindableState { } var zoomLevel: Double { + 15.0 + } + + var initialZoomLevel: Double { switch interactionMode { case .picker: - return 5.0 + return 2.7 case .viewOnly: return 15.0 } @@ -115,6 +109,10 @@ struct StaticLocationScreenViewState: BindableState { struct StaticLocationScreenBindings { var mapCenterLocation: CLLocationCoordinate2D? + + var showsUserLocationMode: ShowUserLocationMode + + var isLocationAuthorized: Bool? /// Information describing the currently displayed alert. var mapError: MapLibreError? { @@ -125,7 +123,7 @@ struct StaticLocationScreenBindings { return nil } set { - alertInfo = newValue.map { AlertInfo(id: .mapError($0)) } + alertInfo = newValue.map { AlertInfo(locationSharingViewError: .mapError($0)) } } } @@ -138,5 +136,33 @@ struct StaticLocationScreenBindings { enum StaticLocationScreenViewAction { case close case selectLocation + case centerToUser case userDidPan } + +extension AlertInfo where T == LocationSharingViewError { + init(locationSharingViewError error: LocationSharingViewError, + primaryButton: AlertButton = AlertButton(title: L10n.actionOk, action: nil), + secondaryButton: AlertButton? = nil) { + switch error { + case .missingAuthorization: + self.init(id: error, + title: "", + message: L10n.errorMissingLocationAuth(InfoPlistReader.main.bundleDisplayName), + primaryButton: primaryButton, + secondaryButton: secondaryButton) + case .mapError(.failedLoadingMap): + self.init(id: error, + title: "", + message: L10n.errorFailedLoadingMap(InfoPlistReader.main.bundleDisplayName), + primaryButton: primaryButton, + secondaryButton: secondaryButton) + case .mapError(.failedLocatingUser): + self.init(id: error, + title: "", + message: L10n.errorFailedLocatingUser(InfoPlistReader.main.bundleDisplayName), + primaryButton: primaryButton, + secondaryButton: secondaryButton) + } + } +} diff --git a/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenCoordinator.swift b/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenCoordinator.swift index abb36812fd..59b6e4b735 100644 --- a/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenCoordinator.swift +++ b/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenCoordinator.swift @@ -51,6 +51,12 @@ final class StaticLocationScreenCoordinator: CoordinatorProtocol { switch action { case .close: actionsSubject.send(.close) + case .openSystemSettings: + guard let url = URL(string: UIApplication.openSettingsURLString), + UIApplication.shared.canOpenURL(url) else { + return + } + UIApplication.shared.open(url) case .sendLocation(let geoURI, let isUserLocation): actionsSubject.send(.selectedLocation(geoURI, isUserLocation: isUserLocation)) } diff --git a/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift b/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift index f6dab2e5f5..bef0567261 100644 --- a/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift +++ b/ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift @@ -38,8 +38,17 @@ class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLo guard let coordinate = state.bindings.mapCenterLocation else { return } actionsSubject.send(.sendLocation(.init(coordinate: coordinate), isUserLocation: state.isSharingUserLocation)) case .userDidPan: - state.showsUserLocationMode = .hide - state.isSharingUserLocation = false + state.bindings.showsUserLocationMode = .show + case .centerToUser: + switch state.bindings.isLocationAuthorized { + case .some(true), .none: + state.bindings.showsUserLocationMode = .showAndFollow + case .some(false): + let action: () -> Void = { [weak self] in self?.actionsSubject.send(.openSystemSettings) } + state.bindings.alertInfo = .init(locationSharingViewError: .missingAuthorization, + primaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil), + secondaryButton: .init(title: L10n.commonSettings, action: action)) + } } } } diff --git a/ElementX/Sources/Screens/LocationSharing/View/StaticLocationScreen.swift b/ElementX/Sources/Screens/LocationSharing/View/StaticLocationScreen.swift index f3321c5d56..2c2d2973ef 100644 --- a/ElementX/Sources/Screens/LocationSharing/View/StaticLocationScreen.swift +++ b/ElementX/Sources/Screens/LocationSharing/View/StaticLocationScreen.swift @@ -44,17 +44,20 @@ struct StaticLocationScreen: View { ZStack(alignment: .center) { MapLibreMapView(builder: builder, options: mapOptions, - showsUserLocationMode: .hide, + showsUserLocationMode: $context.showsUserLocationMode, error: $context.mapError, mapCenterCoordinate: $context.mapCenterLocation, - userDidPan: { - context.send(viewAction: .userDidPan) - }) + isLocationAuthorized: $context.isLocationAuthorized) { + context.send(viewAction: .userDidPan) + } + .ignoresSafeArea(.all, edges: mapSafeAreaEdges) if context.viewState.isLocationPickerMode { LocationMarkerView() } } - .ignoresSafeArea(.all, edges: mapSafeAreaEdges) + .overlay(alignment: .bottomTrailing) { + centerToUserLocationButton + } } // MARK: - Private @@ -72,7 +75,7 @@ struct StaticLocationScreen: View { } } - if context.viewState.showBottomToolbar { + if context.viewState.isLocationPickerMode { ToolbarItemGroup(placement: .bottomBar) { selectLocationButton Spacer() @@ -81,19 +84,22 @@ struct StaticLocationScreen: View { } private var mapOptions: MapLibreMapView.Options { - guard let coordinate = context.viewState.mapAnnotationCoordinate else { - return .init(zoomLevel: context.viewState.zoomLevel) + var annotations: [LocationAnnotation] = [] + if context.viewState.isLocationPickerMode == false { + let annotation = LocationAnnotation(coordinate: context.viewState.initialMapCenter, anchorPoint: .bottomCenter) { + LocationMarkerView() + } + annotations.append(annotation) } return .init(zoomLevel: context.viewState.zoomLevel, - mapCenter: coordinate, - annotations: [LocationAnnotation(coordinate: coordinate, anchorPoint: .bottomCenter) { - LocationMarkerView() - }]) + initialZoomLevel: context.viewState.initialZoomLevel, + mapCenter: context.viewState.initialMapCenter, + annotations: annotations) } private var mapSafeAreaEdges: Edge.Set { - context.viewState.showBottomToolbar ? .horizontal : [.horizontal, .bottom] + context.viewState.isLocationPickerMode ? .horizontal : [.horizontal, .bottom] } @ScaledMetric private var shareMarkerSize: CGFloat = 28 @@ -111,6 +117,15 @@ struct StaticLocationScreen: View { } } + private var centerToUserLocationButton: some View { + Button { + context.send(viewAction: .centerToUser) + } label: { + Image(asset: context.viewState.isSharingUserLocation ? Asset.Images.locationPointerFull : Asset.Images.locationPointer) + } + .padding(16) + } + private var closeButton: some View { Button(L10n.actionCancel) { context.send(viewAction: .close) @@ -127,14 +142,13 @@ struct StaticLocationScreen: View { @ViewBuilder private var shareSheet: some View { - if let location = context.viewState.mapAnnotationCoordinate { - let locationDescription = context.viewState.locationDescription - AppActivityView(activityItems: [ShareToMapsAppActivity.MapsAppType.apple.activityURL(for: location, locationDescription: locationDescription)], - applicationActivities: ShareToMapsAppActivity.MapsAppType.allCases.map { ShareToMapsAppActivity(type: $0, location: location, locationDescription: locationDescription) }) - .edgesIgnoringSafeArea(.bottom) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.hidden) - } + let location = context.viewState.initialMapCenter + let locationDescription = context.viewState.locationDescription + AppActivityView(activityItems: [ShareToMapsAppActivity.MapsAppType.apple.activityURL(for: location, locationDescription: locationDescription)], + applicationActivities: ShareToMapsAppActivity.MapsAppType.allCases.map { ShareToMapsAppActivity(type: $0, location: location, locationDescription: locationDescription) }) + .edgesIgnoringSafeArea(.bottom) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) } } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index 68e6466e44..c7a937c4fe 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -238,7 +238,8 @@ class RoomTimelineController: RoomTimelineControllerProtocol { return .none } } - + + // swiftlint:disable:next cyclomatic_complexity function_body_length private func updateTimelineItems() { var newTimelineItems = [RoomTimelineItemProtocol]() var canBackPaginate = true diff --git a/ElementX/SupportingFiles/Info.plist b/ElementX/SupportingFiles/Info.plist index dfc49dad11..9c7933379a 100644 --- a/ElementX/SupportingFiles/Info.plist +++ b/ElementX/SupportingFiles/Info.plist @@ -28,6 +28,8 @@ NSCameraUsageDescription To take pictures or videos and send them as a message $(APP_DISPLAY_NAME) needs access to the camera. + NSLocationWhenInUseUsageDescription + When you share your location to people, $(APP_DISPLAY_NAME) needs access to show them a map. NSMicrophoneUsageDescription To take videos with audio and send them as a message $(APP_DISPLAY_NAME) needs access to the microphone. NSPhotoLibraryAddUsageDescription diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index 8ffa832d89..4dde9748c9 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -69,6 +69,7 @@ targets: NSCameraUsageDescription: To take pictures or videos and send them as a message $(APP_DISPLAY_NAME) needs access to the camera. NSMicrophoneUsageDescription: To take videos with audio and send them as a message $(APP_DISPLAY_NAME) needs access to the microphone. NSPhotoLibraryAddUsageDescription: Allows saving photos and videos to your library. + NSLocationWhenInUseUsageDescription: When you share your location to people, $(APP_DISPLAY_NAME) needs access to show them a map. UIBackgroundModes: [ fetch ] diff --git a/UnitTests/Sources/StaticLocationScreenViewModelTests.swift b/UnitTests/Sources/StaticLocationScreenViewModelTests.swift index 057ec0db0d..381a487b05 100644 --- a/UnitTests/Sources/StaticLocationScreenViewModelTests.swift +++ b/UnitTests/Sources/StaticLocationScreenViewModelTests.swift @@ -32,13 +32,48 @@ class StaticLocationScreenViewModelTests: XCTestCase { override func setUpWithError() throws { let viewModel = StaticLocationScreenViewModel(interactionMode: .picker) - viewModel.state.isSharingUserLocation = true + viewModel.state.bindings.isLocationAuthorized = true self.viewModel = viewModel } func testUserDidPan() async throws { XCTAssertTrue(context.viewState.isSharingUserLocation) + XCTAssertEqual(context.showsUserLocationMode, .showAndFollow) context.send(viewAction: .userDidPan) XCTAssertFalse(context.viewState.isSharingUserLocation) + XCTAssertEqual(context.showsUserLocationMode, .show) + } + + func testCenterOnUser() async throws { + XCTAssertTrue(context.viewState.isSharingUserLocation) + context.showsUserLocationMode = .show + XCTAssertFalse(context.viewState.isSharingUserLocation) + context.send(viewAction: .centerToUser) + XCTAssertTrue(context.viewState.isSharingUserLocation) + XCTAssertEqual(context.showsUserLocationMode, .showAndFollow) + } + + func testCenterOnUserWithoutAuth() async throws { + context.showsUserLocationMode = .hide + context.isLocationAuthorized = nil + context.send(viewAction: .centerToUser) + XCTAssertEqual(context.showsUserLocationMode, .showAndFollow) + } + + func testCenterOnUserWithDeniedAuth() async throws { + context.isLocationAuthorized = false + context.showsUserLocationMode = .hide + context.send(viewAction: .centerToUser) + XCTAssertNotEqual(context.showsUserLocationMode, .showAndFollow) + XCTAssertNotNil(context.alertInfo) + } + + func testErrorMapping() async throws { + let mapError = AlertInfo(locationSharingViewError: .mapError(.failedLoadingMap)) + XCTAssertEqual(mapError.message, L10n.errorFailedLoadingMap(InfoPlistReader.main.bundleDisplayName)) + let locationError = AlertInfo(locationSharingViewError: .mapError(.failedLocatingUser)) + XCTAssertEqual(locationError.message, L10n.errorFailedLocatingUser(InfoPlistReader.main.bundleDisplayName)) + let authorizationError = AlertInfo(locationSharingViewError: .missingAuthorization) + XCTAssertEqual(authorizationError.message, L10n.errorMissingLocationAuth(InfoPlistReader.main.bundleDisplayName)) } } diff --git a/changelog.d/1272.feature b/changelog.d/1272.feature new file mode 100644 index 0000000000..026ab6ad80 --- /dev/null +++ b/changelog.d/1272.feature @@ -0,0 +1 @@ +Send current user location \ No newline at end of file