mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
implementing static location sheet
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"a11y_pause" = "Pause";
|
||||
"a11y_paused_voice_message" = "Voice message, duration: %1$@, current position: %2$@";
|
||||
"a11y_pin_field" = "PIN field";
|
||||
"a11y_pinned_location" = "Pinned location";
|
||||
"a11y_play" = "Play";
|
||||
"a11y_playback_speed" = "Playback speed";
|
||||
"a11y_poll_end" = "Ended poll";
|
||||
@@ -33,6 +34,7 @@
|
||||
"a11y_remove_reaction" = "Remove reaction: %1$@";
|
||||
"a11y_remove_reaction_with" = "Remove reaction with %1$@";
|
||||
"a11y_send_files" = "Send files";
|
||||
"a11y_sender_location" = "Sender location";
|
||||
"a11y_session_verification_time_limited_action_required" = "Time limited action required, you have one minute to verify";
|
||||
"a11y_show_password" = "Show password";
|
||||
"a11y_start_call" = "Start a call";
|
||||
@@ -771,6 +773,8 @@
|
||||
"screen_start_chat_join_room_by_address_room_found" = "Matching room found";
|
||||
"screen_start_chat_join_room_by_address_room_not_found" = "Room not found";
|
||||
"screen_start_chat_join_room_by_address_supporting_text" = "e.g. #room-name:matrix.org";
|
||||
"screen_static_location_sheet_timestamp_description" = "Shared %1$@";
|
||||
"screen_static_location_sheet_title" = "On the map";
|
||||
"screen_timeline_item_menu_send_failure_changed_identity" = "Message not sent because %1$@’s verified identity was reset.";
|
||||
"screen_timeline_item_menu_send_failure_unsigned_device" = "Message not sent because %1$@ has not verified all devices.";
|
||||
"screen_timeline_item_menu_send_failure_you_unsigned_device" = "Message not sent because you have not verified one or more of your devices.";
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"a11y_pause" = "Pause";
|
||||
"a11y_paused_voice_message" = "Voice message, duration: %1$@, current position: %2$@";
|
||||
"a11y_pin_field" = "PIN field";
|
||||
"a11y_pinned_location" = "Pinned location";
|
||||
"a11y_play" = "Play";
|
||||
"a11y_playback_speed" = "Playback speed";
|
||||
"a11y_poll_end" = "Ended poll";
|
||||
@@ -33,6 +34,7 @@
|
||||
"a11y_remove_reaction" = "Remove reaction: %1$@";
|
||||
"a11y_remove_reaction_with" = "Remove reaction with %1$@";
|
||||
"a11y_send_files" = "Send files";
|
||||
"a11y_sender_location" = "Sender location";
|
||||
"a11y_session_verification_time_limited_action_required" = "Time limited action required, you have one minute to verify";
|
||||
"a11y_show_password" = "Show password";
|
||||
"a11y_start_call" = "Start a call";
|
||||
@@ -771,6 +773,8 @@
|
||||
"screen_start_chat_join_room_by_address_room_found" = "Matching room found";
|
||||
"screen_start_chat_join_room_by_address_room_not_found" = "Room not found";
|
||||
"screen_start_chat_join_room_by_address_supporting_text" = "e.g. #room-name:matrix.org";
|
||||
"screen_static_location_sheet_timestamp_description" = "Shared %1$@";
|
||||
"screen_static_location_sheet_title" = "On the map";
|
||||
"screen_timeline_item_menu_send_failure_changed_identity" = "Message not sent because %1$@’s verified identity was reset.";
|
||||
"screen_timeline_item_menu_send_failure_unsigned_device" = "Message not sent because %1$@ has not verified all devices.";
|
||||
"screen_timeline_item_menu_send_failure_you_unsigned_device" = "Message not sent because you have not verified one or more of your devices.";
|
||||
|
||||
@@ -85,9 +85,8 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
|
||||
actionsSubject.send(.finished)
|
||||
case .displayUser(let userID):
|
||||
actionsSubject.send(.displayUser(userID: userID))
|
||||
case .presentLocationViewer(let senderID, let geoURI):
|
||||
presentMapNavigator(senderID: senderID,
|
||||
geoURI: geoURI,
|
||||
case .presentLocationViewer(let location):
|
||||
presentMapNavigator(location: location,
|
||||
timelineController: timelineController)
|
||||
case .displayMessageForwarding(let forwardingItem):
|
||||
presentMessageForwarding(with: forwardingItem)
|
||||
@@ -100,13 +99,11 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
}
|
||||
|
||||
private func presentMapNavigator(senderID: String?,
|
||||
geoURI: GeoURI,
|
||||
private func presentMapNavigator(location: StaticLocationData,
|
||||
timelineController: TimelineControllerProtocol) {
|
||||
let stackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let params = LocationSharingScreenCoordinatorParameters(interactionMode: .viewStatic(senderID: senderID,
|
||||
geoURI: geoURI),
|
||||
let params = LocationSharingScreenCoordinatorParameters(interactionMode: .viewStatic(location),
|
||||
mapURLBuilder: flowParameters.appSettings.mapTilerConfiguration,
|
||||
liveLocationSharingEnabled: flowParameters.appSettings.liveLocationSharingEnabled,
|
||||
roomProxy: roomProxy,
|
||||
|
||||
@@ -703,9 +703,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case .presentPollForm(let mode):
|
||||
stateMachine.tryEvent(.presentPollForm(mode: mode),
|
||||
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
|
||||
case .presentLocationViewer(let senderID, let geoURI):
|
||||
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .viewStatic(senderID: senderID, geoURI: geoURI)),
|
||||
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
|
||||
case .presentLocationViewer(let location):
|
||||
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .viewStatic(location)),
|
||||
userInfo: EventUserInfo(animated: animated,
|
||||
timelineController: timelineController))
|
||||
case .presentRoomMemberDetails(userID: let userID):
|
||||
stateMachine.tryEvent(.startMembersFlow(entryPoint: .roomMember(userID: userID)))
|
||||
case .presentMessageForwarding(let forwardingItem):
|
||||
@@ -788,9 +789,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case .presentPollForm(let mode):
|
||||
stateMachine.tryEvent(.presentPollForm(mode: mode),
|
||||
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
|
||||
case .presentLocationViewer(let senderID, let geoURI):
|
||||
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .viewStatic(senderID: senderID,
|
||||
geoURI: geoURI)),
|
||||
case .presentLocationViewer(let location):
|
||||
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .viewStatic(location)),
|
||||
userInfo: EventUserInfo(animated: animated,
|
||||
timelineController: timelineController))
|
||||
case .presentEmojiPicker(let itemID, let selectedEmojis):
|
||||
|
||||
@@ -62,6 +62,8 @@ internal enum L10n {
|
||||
}
|
||||
/// PIN field
|
||||
internal static var a11yPinField: String { return L10n.tr("Localizable", "a11y_pin_field") }
|
||||
/// Pinned location
|
||||
internal static var a11yPinnedLocation: String { return L10n.tr("Localizable", "a11y_pinned_location") }
|
||||
/// Play
|
||||
internal static var a11yPlay: String { return L10n.tr("Localizable", "a11y_play") }
|
||||
/// Playback speed
|
||||
@@ -110,6 +112,8 @@ internal enum L10n {
|
||||
}
|
||||
/// Send files
|
||||
internal static var a11ySendFiles: String { return L10n.tr("Localizable", "a11y_send_files") }
|
||||
/// Sender location
|
||||
internal static var a11ySenderLocation: String { return L10n.tr("Localizable", "a11y_sender_location") }
|
||||
/// Time limited action required, you have one minute to verify
|
||||
internal static var a11ySessionVerificationTimeLimitedActionRequired: String { return L10n.tr("Localizable", "a11y_session_verification_time_limited_action_required") }
|
||||
/// Show password
|
||||
@@ -3279,6 +3283,12 @@ internal enum L10n {
|
||||
internal static var screenStartChatJoinRoomByAddressRoomNotFound: String { return L10n.tr("Localizable", "screen_start_chat_join_room_by_address_room_not_found") }
|
||||
/// e.g. #room-name:matrix.org
|
||||
internal static var screenStartChatJoinRoomByAddressSupportingText: String { return L10n.tr("Localizable", "screen_start_chat_join_room_by_address_supporting_text") }
|
||||
/// Shared %1$@
|
||||
internal static func screenStaticLocationSheetTimestampDescription(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "screen_static_location_sheet_timestamp_description", String(describing: p1))
|
||||
}
|
||||
/// On the map
|
||||
internal static var screenStaticLocationSheetTitle: String { return L10n.tr("Localizable", "screen_static_location_sheet_title") }
|
||||
/// Message not sent because %1$@’s verified identity was reset.
|
||||
internal static func screenTimelineItemMenuSendFailureChangedIdentity(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "screen_timeline_item_menu_send_failure_changed_identity", String(describing: p1))
|
||||
|
||||
@@ -24,7 +24,7 @@ struct RowDivider: ViewModifier {
|
||||
}
|
||||
|
||||
extension View {
|
||||
func rowDivider(alignment: Alignment = .bottom, horizontalInsets: CGFloat) -> some View {
|
||||
func rowDivider(alignment: Alignment = .bottom, horizontalInsets: CGFloat = .zero) -> some View {
|
||||
modifier(RowDivider(alignment: alignment, horizontalInsets: horizontalInsets))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ enum TestablePreviewsDictionary {
|
||||
"LinkNewDeviceScreen_Previews" : LinkNewDeviceScreen_Previews.self,
|
||||
"LoadableImage_Previews" : LoadableImage_Previews.self,
|
||||
"LocationMarkerView_Previews" : LocationMarkerView_Previews.self,
|
||||
"LocationPickerSheet_Previews" : LocationPickerSheet_Previews.self,
|
||||
"LocationRoomTimelineView_Previews" : LocationRoomTimelineView_Previews.self,
|
||||
"LocationSharingScreen_Previews" : LocationSharingScreen_Previews.self,
|
||||
"LoginScreen_Previews" : LoginScreen_Previews.self,
|
||||
@@ -182,6 +183,7 @@ enum TestablePreviewsDictionary {
|
||||
"StackedAvatarsView_Previews" : StackedAvatarsView_Previews.self,
|
||||
"StartChatScreen_Previews" : StartChatScreen_Previews.self,
|
||||
"StateRoomTimelineView_Previews" : StateRoomTimelineView_Previews.self,
|
||||
"StaticLocationSheet_Previews" : StaticLocationSheet_Previews.self,
|
||||
"StickerRoomTimelineView_Previews" : StickerRoomTimelineView_Previews.self,
|
||||
"SwipeRightAction_Previews" : SwipeRightAction_Previews.self,
|
||||
"SwipeToReplyView_Previews" : SwipeToReplyView_Previews.self,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
enum LocationSharingViewError: Error, Hashable {
|
||||
case missingAuthorization
|
||||
@@ -21,25 +22,18 @@ enum LocationSharingScreenViewModelAction {
|
||||
|
||||
enum LocationSharingInteractionMode: Hashable {
|
||||
case picker
|
||||
case viewStatic(senderID: String?, geoURI: GeoURI)
|
||||
|
||||
var canShowAvatar: Bool {
|
||||
switch self {
|
||||
case .picker, .viewStatic(.some(_), _):
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
case viewStatic(StaticLocationData)
|
||||
}
|
||||
|
||||
struct LocationSharingScreenViewState: BindableState {
|
||||
init(interactionMode: LocationSharingInteractionMode,
|
||||
mapURLBuilder: MapTilerURLBuilderProtocol,
|
||||
showLiveLocationSharingButton: Bool) {
|
||||
showLiveLocationSharingButton: Bool,
|
||||
ownUserID: String) {
|
||||
self.interactionMode = interactionMode
|
||||
self.mapURLBuilder = mapURLBuilder
|
||||
self.showLiveLocationSharingButton = showLiveLocationSharingButton
|
||||
self.ownUserID = ownUserID
|
||||
|
||||
bindings.showsUserLocationMode = switch interactionMode {
|
||||
case .picker: .showAndFollow
|
||||
@@ -50,6 +44,7 @@ struct LocationSharingScreenViewState: BindableState {
|
||||
let interactionMode: LocationSharingInteractionMode
|
||||
let mapURLBuilder: MapTilerURLBuilderProtocol
|
||||
let showLiveLocationSharingButton: Bool
|
||||
let ownUserID: String
|
||||
|
||||
var bindings = LocationSharingScreenBindings(showsUserLocationMode: .hide)
|
||||
|
||||
@@ -62,9 +57,9 @@ struct LocationSharingScreenViewState: BindableState {
|
||||
switch interactionMode {
|
||||
case .picker:
|
||||
// middle point in Europe, to be used if the users location is not yet known
|
||||
return .init(latitude: 49.843, longitude: 9.902056)
|
||||
case .viewStatic(_, let geoURI):
|
||||
return .init(latitude: geoURI.latitude, longitude: geoURI.longitude)
|
||||
.init(latitude: 49.843, longitude: 9.902056)
|
||||
case .viewStatic(let location):
|
||||
.init(latitude: location.geoURI.latitude, longitude: location.geoURI.longitude)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,15 +71,6 @@ struct LocationSharingScreenViewState: BindableState {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
var showShareAction: Bool {
|
||||
switch interactionMode {
|
||||
case .picker:
|
||||
return false
|
||||
case .viewStatic:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var isLocationLoading: Bool {
|
||||
!bindings.hasLoadedUserLocation && bindings.isLocationAuthorized != false
|
||||
@@ -105,12 +91,12 @@ struct LocationSharingScreenViewState: BindableState {
|
||||
|
||||
var userProfile: UserProfileProxy?
|
||||
|
||||
var shownUserProfile: UserProfileProxy? {
|
||||
var staticLocationMarkerUserProfile: UserProfileProxy? {
|
||||
switch interactionMode {
|
||||
case .picker:
|
||||
isSharingUserLocation ? userProfile : nil
|
||||
case .viewStatic:
|
||||
userProfile
|
||||
case .viewStatic(let location):
|
||||
location.kind == .sender ? userProfile : nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,3 +158,10 @@ extension AlertInfo where T == LocationSharingViewError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StaticLocationData: Hashable {
|
||||
let sender: TimelineItemSender
|
||||
let geoURI: GeoURI
|
||||
let kind: StaticLocationKind
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
@@ -37,13 +37,12 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
||||
|
||||
super.init(initialViewState: .init(interactionMode: interactionMode,
|
||||
mapURLBuilder: mapURLBuilder,
|
||||
showLiveLocationSharingButton: liveLocationSharingEnabled),
|
||||
showLiveLocationSharingButton: liveLocationSharingEnabled,
|
||||
ownUserID: roomProxy.ownUserID),
|
||||
mediaProvider: mediaProvider)
|
||||
|
||||
if interactionMode.canShowAvatar {
|
||||
updateShownUserProfile(members: roomProxy.membersPublisher.value)
|
||||
setupSubscriptions()
|
||||
}
|
||||
updateShownUserProfile(members: roomProxy.membersPublisher.value)
|
||||
setupSubscriptions()
|
||||
}
|
||||
|
||||
override func process(viewAction: LocationSharingScreenViewAction) {
|
||||
@@ -86,14 +85,12 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
||||
} else {
|
||||
state.userProfile = .init(userID: roomProxy.ownUserID)
|
||||
}
|
||||
case .viewStatic(.some(let senderID), _):
|
||||
if let sender = members.first(where: { $0.userID == senderID }).map(UserProfileProxy.init) {
|
||||
case .viewStatic(let location):
|
||||
if let sender = members.first(where: { $0.userID == location.sender.id }).map(UserProfileProxy.init) {
|
||||
state.userProfile = sender
|
||||
} else {
|
||||
state.userProfile = .init(userID: senderID)
|
||||
state.userProfile = .init(sender: location.sender)
|
||||
}
|
||||
default:
|
||||
state.userProfile = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,3 +124,39 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
||||
"\(Self.self)-Status"
|
||||
}
|
||||
}
|
||||
|
||||
extension LocationSharingScreenViewModel {
|
||||
enum MockType {
|
||||
case picker
|
||||
case staticSenderLocation
|
||||
case staticPinLocation
|
||||
}
|
||||
|
||||
static func mock(type: MockType, senderID: String = "@dan:matrix.org") -> LocationSharingScreenViewModel {
|
||||
let interactionMode: LocationSharingInteractionMode = switch type {
|
||||
case .picker:
|
||||
.picker
|
||||
case .staticPinLocation:
|
||||
.viewStatic(.init(sender: .init(id: senderID),
|
||||
geoURI: .init(latitude: 41.9027835,
|
||||
longitude: 12.4963655),
|
||||
kind: .pin,
|
||||
timestamp: .mock))
|
||||
case .staticSenderLocation:
|
||||
.viewStatic(.init(sender: .init(id: senderID),
|
||||
geoURI: .init(latitude: 41.9027835,
|
||||
longitude: 12.4963655),
|
||||
kind: .sender,
|
||||
timestamp: .mock))
|
||||
}
|
||||
|
||||
return LocationSharingScreenViewModel(interactionMode: interactionMode,
|
||||
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
|
||||
liveLocationSharingEnabled: true,
|
||||
roomProxy: JoinedRoomProxyMock(.init()),
|
||||
timelineController: MockTimelineController(),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct LocationPickerSheet: View {
|
||||
@Bindable var context: LocationSharingScreenViewModel.Context
|
||||
@State private var height: CGFloat = .zero
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button {
|
||||
context.send(viewAction: .selectLocation)
|
||||
} label: {
|
||||
if context.viewState.isSharingUserLocation {
|
||||
LocationPickerLabel(text: L10n.screenShareMyLocationAction,
|
||||
icon: \.locationNavigatorCentred,
|
||||
iconColor: .compound.iconSecondary)
|
||||
} else {
|
||||
LocationPickerLabel(text: L10n.screenShareThisLocationAction,
|
||||
icon: \.locationNavigator,
|
||||
iconColor: .compound.iconSecondary)
|
||||
}
|
||||
}
|
||||
if context.viewState.showLiveLocationSharingButton {
|
||||
Button { } label: {
|
||||
LocationPickerLabel(text: L10n.actionShareLiveLocation,
|
||||
icon: \.locationPinSolid,
|
||||
iconColor: .compound.iconAccentPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.padding(.top, 38)
|
||||
.readHeight($height)
|
||||
.interactiveDismissDisabled()
|
||||
.presentationBackground(.compound.bgCanvasDefault)
|
||||
.presentationBackgroundInteraction(.enabled)
|
||||
.presentationDragIndicator(.hidden)
|
||||
.presentationDetents([.height(height)])
|
||||
}
|
||||
}
|
||||
|
||||
private struct LocationPickerLabel: View {
|
||||
let text: String
|
||||
let icon: KeyPath<CompoundIcons, Image>
|
||||
let iconColor: Color
|
||||
|
||||
var body: some View {
|
||||
Label {
|
||||
Text(text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 14)
|
||||
.rowDivider(alignment: .top)
|
||||
.padding(.trailing, 16)
|
||||
} icon: {
|
||||
CompoundIcon(icon)
|
||||
.foregroundStyle(iconColor)
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct LocationPickerSheet_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = LocationSharingScreenViewModel.mock(type: .picker)
|
||||
|
||||
static var previews: some View {
|
||||
LocationPickerSheet(context: viewModel.context)
|
||||
}
|
||||
}
|
||||
@@ -13,15 +13,19 @@ struct LocationSharingScreen: View {
|
||||
@Bindable var context: LocationSharingScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
if context.viewState.isLocationPickerMode {
|
||||
switch context.viewState.interactionMode {
|
||||
case .picker:
|
||||
mainContent
|
||||
.sheet(isPresented: .constant(true)) {
|
||||
sharingOptionsSheet
|
||||
LocationPickerSheet(context: context)
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
} else {
|
||||
case .viewStatic:
|
||||
mainContent
|
||||
.alert(item: $context.alertInfo)
|
||||
.sheet(isPresented: .constant(true)) {
|
||||
StaticLocationSheet(context: context)
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +55,7 @@ struct LocationSharingScreen: View {
|
||||
.ignoresSafeArea(.all, edges: mapSafeAreaEdges)
|
||||
|
||||
if context.viewState.isLocationPickerMode {
|
||||
LocationMarkerView(userProfile: context.viewState.shownUserProfile, mediaProvider: context.mediaProvider)
|
||||
LocationMarkerView(userProfile: context.viewState.staticLocationMarkerUserProfile, mediaProvider: context.mediaProvider)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
@@ -61,32 +65,21 @@ struct LocationSharingScreen: View {
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: context.viewState.showShareAction ? .topBarLeading : .topBarTrailing) {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
ToolbarButton(role: .close) {
|
||||
context.send(viewAction: .close)
|
||||
}
|
||||
}
|
||||
|
||||
if context.viewState.showShareAction {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
context.showShareSheet = true
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
.popover(isPresented: $context.showShareSheet) { shareSheet }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mapOptions: MapLibreMapView.Options {
|
||||
var annotations: [String: LocationAnnotation] = [:]
|
||||
if !context.viewState.isLocationPickerMode {
|
||||
let id = context.viewState.shownUserProfile?.userID ?? UUID().uuidString
|
||||
let id = context.viewState.staticLocationMarkerUserProfile?.userID ?? UUID().uuidString
|
||||
let annotation = LocationAnnotation(id: id,
|
||||
coordinate: context.viewState.initialMapCenter,
|
||||
anchorPoint: .bottomCenter) {
|
||||
LocationMarkerView(userProfile: context.viewState.shownUserProfile, mediaProvider: context.mediaProvider)
|
||||
LocationMarkerView(userProfile: context.viewState.staticLocationMarkerUserProfile, mediaProvider: context.mediaProvider)
|
||||
}
|
||||
annotations[id] = annotation
|
||||
}
|
||||
@@ -135,7 +128,7 @@ struct LocationSharingScreen: View {
|
||||
@ViewBuilder
|
||||
private var shareSheet: some View {
|
||||
let location = context.viewState.initialMapCenter
|
||||
let senderName = context.viewState.shownUserProfile?.displayName
|
||||
let senderName = context.viewState.staticLocationMarkerUserProfile?.displayName ?? context.viewState.staticLocationMarkerUserProfile?.userID
|
||||
AppActivityView(activityItems: [ShareToMapsAppActivity.MapsAppType.apple.activityURL(for: location, senderName: senderName)],
|
||||
applicationActivities: ShareToMapsAppActivity.MapsAppType.allCases.map { ShareToMapsAppActivity(type: $0, location: location, senderName: senderName) })
|
||||
.edgesIgnoringSafeArea(.bottom)
|
||||
@@ -151,95 +144,16 @@ struct LocationSharingScreen: View {
|
||||
.sheet
|
||||
}
|
||||
}
|
||||
|
||||
@State private var sharingOptionsSheetHeight: CGFloat = .zero
|
||||
|
||||
private var sharingOptionsSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button {
|
||||
context.send(viewAction: .selectLocation)
|
||||
} label: {
|
||||
if context.viewState.isSharingUserLocation {
|
||||
LocationSharingLabel(text: L10n.screenShareMyLocationAction,
|
||||
icon: \.locationNavigatorCentred,
|
||||
iconColor: .compound.iconSecondary)
|
||||
} else {
|
||||
LocationSharingLabel(text: L10n.screenShareThisLocationAction,
|
||||
icon: \.locationNavigator,
|
||||
iconColor: .compound.iconSecondary)
|
||||
}
|
||||
}
|
||||
if context.viewState.showLiveLocationSharingButton {
|
||||
Button { } label: {
|
||||
LocationSharingLabel(text: L10n.actionShareLiveLocation,
|
||||
icon: \.locationPinSolid,
|
||||
iconColor: .compound.iconAccentPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.padding(.top, 38)
|
||||
.readHeight($sharingOptionsSheetHeight)
|
||||
.interactiveDismissDisabled()
|
||||
.presentationBackground(.compound.bgCanvasDefault)
|
||||
.presentationBackgroundInteraction(.enabled)
|
||||
.presentationDragIndicator(.hidden)
|
||||
.presentationDetents([.height(sharingOptionsSheetHeight)])
|
||||
}
|
||||
}
|
||||
|
||||
private struct LocationSharingLabel: View {
|
||||
let text: String
|
||||
let icon: KeyPath<CompoundIcons, Image>
|
||||
let iconColor: Color
|
||||
|
||||
var body: some View {
|
||||
Label {
|
||||
Text(text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 14)
|
||||
.rowDivider(alignment: .top, horizontalInsets: 16.0)
|
||||
} icon: {
|
||||
CompoundIcon(icon)
|
||||
.foregroundStyle(iconColor)
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct LocationSharingScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = LocationSharingScreenViewModel(interactionMode: .viewStatic(senderID: "@dan:matrix.org", geoURI: .init(latitude: 41.9027835,
|
||||
longitude: 12.4963655)),
|
||||
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
|
||||
liveLocationSharingEnabled: true,
|
||||
roomProxy: JoinedRoomProxyMock(.init()),
|
||||
timelineController: MockTimelineController(),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()))
|
||||
static let viewModel = LocationSharingScreenViewModel.mock(type: .staticSenderLocation)
|
||||
|
||||
static let pinViewModel = LocationSharingScreenViewModel(interactionMode: .viewStatic(senderID: nil, geoURI: .init(latitude: 41.9027835,
|
||||
longitude: 12.4963655)),
|
||||
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
|
||||
liveLocationSharingEnabled: true,
|
||||
roomProxy: JoinedRoomProxyMock(.init()),
|
||||
timelineController: MockTimelineController(),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()))
|
||||
static let pinViewModel = LocationSharingScreenViewModel.mock(type: .staticPinLocation)
|
||||
|
||||
static let pickerViewModel = LocationSharingScreenViewModel(interactionMode: .picker,
|
||||
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
|
||||
liveLocationSharingEnabled: true,
|
||||
roomProxy: JoinedRoomProxyMock(.init()),
|
||||
timelineController: MockTimelineController(),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()))
|
||||
static let pickerViewModel = LocationSharingScreenViewModel.mock(type: .picker)
|
||||
|
||||
static var previews: some View {
|
||||
ElementNavigationStack {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct StaticLocationSheet: View {
|
||||
@Bindable var context: LocationSharingScreenViewModel.Context
|
||||
@State private var height = CGFloat.zero
|
||||
|
||||
var body: some View {
|
||||
mainContent
|
||||
.readHeight($height)
|
||||
.interactiveDismissDisabled()
|
||||
.presentationBackground(.compound.bgCanvasDefault)
|
||||
.presentationBackgroundInteraction(.enabled)
|
||||
.presentationDragIndicator(.hidden)
|
||||
.presentationDetents([.height(height)])
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
if case let .viewStatic(location) = context.viewState.interactionMode,
|
||||
let userProfile = context.viewState.userProfile {
|
||||
VStack(spacing: 0) {
|
||||
Text(L10n.screenStaticLocationSheetTitle)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.font(.compound.bodyLGSemibold)
|
||||
.padding(.top, 29)
|
||||
.padding(.bottom, 25)
|
||||
Button {
|
||||
context.showShareSheet = true
|
||||
} label: {
|
||||
UserLocationCell(profile: userProfile,
|
||||
isOwnUser: userProfile.userID == context.viewState.ownUserID,
|
||||
isUserLocation: location.kind == .sender,
|
||||
timestamp: location.timestamp,
|
||||
mediaProvider: context.mediaProvider)
|
||||
}
|
||||
accessibilityHint(L10n.actionShare)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This may be reused for live location sharing sheet in the future with some tweaks
|
||||
private struct UserLocationCell: View {
|
||||
let profile: UserProfileProxy
|
||||
let isOwnUser: Bool
|
||||
let isUserLocation: Bool
|
||||
let timestamp: Date
|
||||
var mediaProvider: MediaProviderProtocol?
|
||||
|
||||
private var name: String {
|
||||
isOwnUser ? L10n.commonYou : profile.displayName ?? profile.userID
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
LoadableAvatarImage(url: profile.avatarURL,
|
||||
name: profile.displayName,
|
||||
contentID: profile.id,
|
||||
avatarSize: .user(on: .map),
|
||||
mediaProvider: mediaProvider)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(name)
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
HStack(spacing: 4) {
|
||||
CompoundIcon(isUserLocation ? \.locationNavigatorCentred : \.locationNavigator,
|
||||
size: .xSmall,
|
||||
relativeTo: .compound.bodyMD)
|
||||
.foregroundStyle(.compound.iconSecondary)
|
||||
.accessibilityLabel(isUserLocation ? L10n.a11ySenderLocation : L10n.a11yPinnedLocation)
|
||||
Text(L10n.screenStaticLocationSheetTimestampDescription(timestamp.formatted(.relative(presentation: .named))))
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundStyle(.compound.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
CompoundIcon(\.shareIos)
|
||||
.foregroundStyle(.compound.iconSecondary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.rowDivider(alignment: .top)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
struct StaticLocationSheet_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = LocationSharingScreenViewModel.mock(type: .staticSenderLocation, senderID: RoomMemberProxyMock.mockMe.userID)
|
||||
|
||||
static let pinViewModel = LocationSharingScreenViewModel.mock(type: .staticPinLocation)
|
||||
|
||||
static var previews: some View {
|
||||
StaticLocationSheet(context: viewModel.context)
|
||||
.previewDisplayName("Static own location")
|
||||
StaticLocationSheet(context: pinViewModel.context)
|
||||
.previewDisplayName("Static pin location")
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -26,7 +26,7 @@ struct PinnedEventsTimelineScreenCoordinatorParameters {
|
||||
enum PinnedEventsTimelineScreenCoordinatorAction {
|
||||
case dismiss
|
||||
case displayUser(userID: String)
|
||||
case presentLocationViewer(senderID: String?, geoURI: GeoURI)
|
||||
case presentLocationViewer(StaticLocationData)
|
||||
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
|
||||
case displayRoomScreenWithFocussedPin(eventID: String, threadRootEventID: String?)
|
||||
}
|
||||
@@ -87,9 +87,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
|
||||
actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem))
|
||||
case .displayMediaPreview(let mediaPreviewViewModel):
|
||||
viewModel.displayMediaPreview(mediaPreviewViewModel)
|
||||
case .displayLocation(let senderID, let geoURI):
|
||||
actionsSubject.send(.presentLocationViewer(senderID: senderID,
|
||||
geoURI: geoURI))
|
||||
case .displayLocation(let location):
|
||||
actionsSubject.send(.presentLocationViewer(location))
|
||||
case .viewInRoomTimeline(let eventID, let threadRootEventID):
|
||||
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID, threadRootEventID: threadRootEventID))
|
||||
// These other actions will not be handled in this view
|
||||
|
||||
@@ -39,7 +39,7 @@ enum RoomScreenCoordinatorAction {
|
||||
case presentRoomDetails
|
||||
case presentLocationPicker
|
||||
case presentPollForm(mode: PollFormMode)
|
||||
case presentLocationViewer(senderID: String?, geoURI: GeoURI)
|
||||
case presentLocationViewer(StaticLocationData)
|
||||
case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
|
||||
case presentRoomMemberDetails(userID: String)
|
||||
case presentMessageForwarding(forwardingItem: MessageForwardingItem)
|
||||
@@ -137,8 +137,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
actionsSubject.send(.presentRoomMemberDetails(userID: userID))
|
||||
case .displayMessageForwarding(let forwardingItem):
|
||||
actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem))
|
||||
case .displayLocation(let senderID, let geoURI):
|
||||
actionsSubject.send(.presentLocationViewer(senderID: senderID, geoURI: geoURI))
|
||||
case .displayLocation(let location):
|
||||
actionsSubject.send(.presentLocationViewer(location))
|
||||
case .displayResolveSendFailure(let failure, let sendHandle):
|
||||
actionsSubject.send(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle))
|
||||
case .displayThread(let itemID):
|
||||
|
||||
@@ -33,7 +33,7 @@ enum ThreadTimelineScreenCoordinatorAction {
|
||||
case presentMediaUploadPicker(mode: MediaPickerScreenMode)
|
||||
case presentMediaUploadPreviewScreen(mediaURLs: [URL])
|
||||
case presentLocationPicker
|
||||
case presentLocationViewer(senderID: String?, geoURI: GeoURI)
|
||||
case presentLocationViewer(StaticLocationData)
|
||||
case presentPollForm(mode: PollFormMode)
|
||||
case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
|
||||
case presentRoomMemberDetails(userID: String)
|
||||
@@ -118,9 +118,8 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
|
||||
viewModel.displayMediaPreview(mediaPreviewViewModel)
|
||||
case .displayLocationPicker:
|
||||
actionsSubject.send(.presentLocationPicker)
|
||||
case .displayLocation(let senderID, let geoURI):
|
||||
actionsSubject.send(.presentLocationViewer(senderID: senderID,
|
||||
geoURI: geoURI))
|
||||
case .displayLocation(let location):
|
||||
actionsSubject.send(.presentLocationViewer(location))
|
||||
case .displayPollForm(let mode):
|
||||
actionsSubject.send(.presentPollForm(mode: mode))
|
||||
case .displayMediaUploadPreviewScreen(let mediaURLs):
|
||||
|
||||
@@ -536,8 +536,10 @@ class TimelineInteractionHandler {
|
||||
switch timelineItem {
|
||||
case let item as LocationRoomTimelineItem:
|
||||
guard let geoURI = item.content.geoURI else { return .none }
|
||||
return .displayLocation(senderID: item.content.kind == .sender ? item.sender.id : nil,
|
||||
geoURI: geoURI)
|
||||
return .displayLocation(.init(sender: item.sender,
|
||||
geoURI: geoURI,
|
||||
kind: item.content.kind,
|
||||
timestamp: item.timestamp))
|
||||
case is ImageRoomTimelineItem,
|
||||
is VideoRoomTimelineItem:
|
||||
return await mediaPreviewAction(for: timelineItem, messageTypes: [.image, .video])
|
||||
|
||||
@@ -23,7 +23,7 @@ enum TimelineViewModelAction {
|
||||
case displaySenderDetails(userID: String)
|
||||
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
|
||||
case displayMediaPreview(TimelineMediaPreviewViewModel)
|
||||
case displayLocation(senderID: String?, geoURI: GeoURI)
|
||||
case displayLocation(StaticLocationData)
|
||||
case displayResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
|
||||
case displayThread(itemID: TimelineItemIdentifier)
|
||||
case composer(action: TimelineComposerAction)
|
||||
|
||||
@@ -655,8 +655,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
|
||||
let mediaPreviewViewModel = makeMediaPreviewViewModel(item: item, timelineViewModelKind: timelineViewModelKind)
|
||||
actionsSubject.send(.displayMediaPreview(mediaPreviewViewModel))
|
||||
case .displayLocation(let senderID, let geoURI):
|
||||
actionsSubject.send(.displayLocation(senderID: senderID, geoURI: geoURI))
|
||||
case .displayLocation(let location):
|
||||
actionsSubject.send(.displayLocation(location))
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ struct LocationRoomTimelineView: View {
|
||||
MapLibreStaticMapView(geoURI: geoURI,
|
||||
mapURLBuilder: context.viewState.mapTilerConfiguration,
|
||||
mapSize: .init(width: mapAspectRatio * mapMaxHeight, height: mapMaxHeight)) {
|
||||
LocationMarkerView(userProfile: timelineItem.content.kind == .sender ? .init(sender: timelineItem.sender) : nil, mediaProvider: context.mediaProvider)
|
||||
LocationMarkerView(userProfile: timelineItem.content.kind == .sender ? .init(sender: timelineItem.sender) : nil,
|
||||
mediaProvider: context.mediaProvider)
|
||||
}
|
||||
.frame(maxHeight: mapMaxHeight)
|
||||
.aspectRatio(mapAspectRatio, contentMode: .fit)
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ enum TimelineControllerAction {
|
||||
}
|
||||
|
||||
case displayMediaPreview(item: EventBasedMessageTimelineItemProtocol, timelineViewModel: TimelineViewModelKind)
|
||||
case displayLocation(senderID: String?, geoURI: GeoURI)
|
||||
case displayLocation(StaticLocationData)
|
||||
case none
|
||||
}
|
||||
|
||||
|
||||
+16
-16
@@ -9,29 +9,29 @@
|
||||
import MatrixRustSDK
|
||||
|
||||
struct LocationRoomTimelineItemContent: Hashable {
|
||||
enum Kind {
|
||||
case sender
|
||||
case pin
|
||||
|
||||
init(from asset: AssetType?) {
|
||||
self = switch asset {
|
||||
case .pin:
|
||||
.pin
|
||||
case .sender, .none:
|
||||
.sender
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let body: String
|
||||
let geoURI: GeoURI?
|
||||
let kind: Kind
|
||||
let kind: StaticLocationKind
|
||||
|
||||
init(body: String,
|
||||
geoURI: GeoURI? = nil,
|
||||
kind: Kind = .sender) {
|
||||
kind: StaticLocationKind = .sender) {
|
||||
self.body = body
|
||||
self.geoURI = geoURI
|
||||
self.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
enum StaticLocationKind {
|
||||
case sender
|
||||
case pin
|
||||
|
||||
init(from asset: AssetType?) {
|
||||
switch asset {
|
||||
case .pin:
|
||||
self = .pin
|
||||
default:
|
||||
self = .sender
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user