Merge branch 'develop' into maximee/6029_lls_timeline_cell and apply comments

This commit is contained in:
MaximeE
2022-04-25 15:53:58 +02:00
57 changed files with 1680 additions and 155 deletions
@@ -0,0 +1,87 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct LiveLocationSharingViewerCoordinatorParameters {
let session: MXSession
}
final class LiveLocationSharingViewerCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: LiveLocationSharingViewerCoordinatorParameters
private let liveLocationSharingViewerHostingController: UIViewController
private var liveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelProtocol
private let shareLocationActivityControllerBuilder = ShareLocationActivityControllerBuilder()
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: (() -> Void)?
// MARK: - Setup
@available(iOS 14.0, *)
init(parameters: LiveLocationSharingViewerCoordinatorParameters) {
self.parameters = parameters
let service = LiveLocationSharingViewerService(session: parameters.session)
let viewModel = LiveLocationSharingViewerViewModel(mapStyleURL: BuildSettings.tileServerMapStyleURL, service: service)
let view = LiveLocationSharingViewer(viewModel: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager))
liveLocationSharingViewerViewModel = viewModel
liveLocationSharingViewerHostingController = VectorHostingController(rootView: view)
}
// MARK: - Public
func start() {
MXLog.debug("[LiveLocationSharingViewerCoordinator] did start.")
liveLocationSharingViewerViewModel.completion = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[LiveLocationSharingViewerCoordinator] LiveLocationSharingViewerViewModel did complete with result: \(result).")
switch result {
case .done:
self.completion?()
case .share(let coordinate):
self.presentLocationActivityController(with: coordinate)
case .stopLocationSharing:
self.stopLocationSharing()
}
}
}
func toPresentable() -> UIViewController {
return self.liveLocationSharingViewerHostingController
}
func presentLocationActivityController(with coordinate: CLLocationCoordinate2D) {
let shareActivityController = shareLocationActivityControllerBuilder.build(with: coordinate)
self.liveLocationSharingViewerHostingController.present(shareActivityController, animated: true)
}
func stopLocationSharing() {
// TODO: Handle stop location sharing
}
}
@@ -0,0 +1,68 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Combine
import CoreLocation
// MARK: - Coordinator
// MARK: View model
enum LiveLocationSharingViewerViewModelResult {
case done
case share(_ coordinate: CLLocationCoordinate2D)
case stopLocationSharing
}
// MARK: View
@available(iOS 14, *)
struct LiveLocationSharingViewerViewState: BindableState {
/// Map style URL
let mapStyleURL: URL
/// Map annotations to display on map
var annotations: [UserLocationAnnotation]
/// Map annotation to focus on
var highlightedAnnotation: UserLocationAnnotation?
/// Live location list items
var listItemsViewData: [LiveLocationListItemViewData]
var showLoadingIndicator: Bool = false
var shareButtonEnabled: Bool {
!showLoadingIndicator
}
let errorSubject = PassthroughSubject<LocationSharingViewError, Never>()
var bindings = LocationSharingViewStateBindings()
}
struct LiveLocationSharingViewerViewStateBindings {
var alertInfo: AlertInfo<LocationSharingAlertType>?
}
enum LiveLocationSharingViewerViewAction {
case done
case stopSharing
case tapListItem(_ userId: String)
case share(_ annotation: UserLocationAnnotation)
}
@@ -0,0 +1,181 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
import Combine
import Mapbox
@available(iOS 14, *)
typealias LiveLocationSharingViewerViewModelType = StateStoreViewModel<LiveLocationSharingViewerViewState,
Never,
LiveLocationSharingViewerViewAction>
@available(iOS 14, *)
class LiveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelType, LiveLocationSharingViewerViewModelProtocol {
// MARK: - Properties
// MARK: Private
private let liveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol
private var mapViewErrorAlertInfoBuilder: MapViewErrorAlertInfoBuilder
// MARK: Public
var completion: ((LiveLocationSharingViewerViewModelResult) -> Void)?
// MARK: - Setup
init(mapStyleURL: URL, service: LiveLocationSharingViewerServiceProtocol) {
let viewState = LiveLocationSharingViewerViewState(mapStyleURL: mapStyleURL, annotations: [], highlightedAnnotation: nil, listItemsViewData: [])
liveLocationSharingViewerService = service
mapViewErrorAlertInfoBuilder = MapViewErrorAlertInfoBuilder()
super.init(initialViewState: viewState)
state.errorSubject.sink { [weak self] error in
guard let self = self else { return }
self.processError(error)
}.store(in: &cancellables)
self.update(with: service.usersLiveLocation)
}
// MARK: - Public
override func process(viewAction: LiveLocationSharingViewerViewAction) {
switch viewAction {
case .done:
completion?(.done)
case .stopSharing:
completion?(.stopLocationSharing)
case .tapListItem(let userId):
self.highlighAnnotation(with: userId)
case .share(let userLocationAnnotation):
completion?(.share(userLocationAnnotation.coordinate))
}
}
// MARK: - Private
private func processError(_ error: LocationSharingViewError) {
guard state.bindings.alertInfo == nil else {
return
}
let alertInfo = mapViewErrorAlertInfoBuilder.build(with: error) { [weak self] in
switch error {
case .invalidLocationAuthorization:
if let applicationSettingsURL = URL(string:UIApplication.openSettingsURLString) {
UIApplication.shared.open(applicationSettingsURL)
} else {
self?.completion?(.done)
}
default:
self?.completion?(.done)
}
}
state.bindings.alertInfo = alertInfo
}
private func userLocationAnnotations(from usersLiveLocation: [UserLiveLocation]) -> [UserLocationAnnotation] {
return usersLiveLocation.map { userLiveLocation in
return UserLocationAnnotation(avatarData: userLiveLocation.avatarData, coordinate: userLiveLocation.coordinate)
}
}
private func currentUserLocationAnnotation(from annotations: [UserLocationAnnotation]) -> UserLocationAnnotation? {
annotations.first { annotation in
return liveLocationSharingViewerService.isCurrentUserId(annotation.userId)
}
}
private func getHighlightedAnnotation(from annotations: [UserLocationAnnotation]) -> UserLocationAnnotation? {
if let userAnnotation = self.currentUserLocationAnnotation(from: annotations) {
return userAnnotation
} else {
return annotations.first
}
}
private func listItemsViewData(from usersLiveLocation: [UserLiveLocation]) -> [LiveLocationListItemViewData] {
var listItemsViewData: [LiveLocationListItemViewData] = []
let sortedUsersLiveLocation = usersLiveLocation.sorted { userLiveLocation1, userLiveLocation2 in
return userLiveLocation1.displayName > userLiveLocation2.displayName
}
listItemsViewData = sortedUsersLiveLocation.map({ userLiveLocation in
return self.listItemViewData(from: userLiveLocation)
})
let currentUserIndex = listItemsViewData.firstIndex { viewData in
return viewData.isCurrentUser
}
// Move current user as first item
if let currentUserIndex = currentUserIndex {
let currentUserViewData = listItemsViewData[currentUserIndex]
listItemsViewData.remove(at: currentUserIndex)
listItemsViewData.insert(currentUserViewData, at: 0)
}
return listItemsViewData
}
private func listItemViewData(from userLiveLocation: UserLiveLocation) -> LiveLocationListItemViewData {
let isCurrentUser = self.liveLocationSharingViewerService.isCurrentUserId(userLiveLocation.userId)
let expirationDate = userLiveLocation.timestamp + userLiveLocation.timeout
return LiveLocationListItemViewData(userId: userLiveLocation.userId, isCurrentUser: isCurrentUser, avatarData: userLiveLocation.avatarData, displayName: userLiveLocation.displayName, expirationDate: expirationDate, lastUpdate: userLiveLocation.lastUpdate)
}
private func update(with usersLiveLocation: [UserLiveLocation]) {
let annotations: [UserLocationAnnotation] = self.userLocationAnnotations(from: usersLiveLocation)
let highlightedAnnotation = self.getHighlightedAnnotation(from: annotations)
let listViewItems = self.listItemsViewData(from: usersLiveLocation)
self.state.annotations = annotations
self.state.highlightedAnnotation = highlightedAnnotation
self.state.listItemsViewData = listViewItems
}
private func highlighAnnotation(with userId: String) {
let foundUserAnnotation = self.state.annotations.first { annotation in
annotation.userId == userId
}
guard let foundUserAnnotation = foundUserAnnotation else {
return
}
self.state.highlightedAnnotation = foundUserAnnotation
}
}
@@ -0,0 +1,24 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
protocol LiveLocationSharingViewerViewModelProtocol {
var completion: ((LiveLocationSharingViewerViewModelResult) -> Void)? { get set }
@available(iOS 14, *)
var context: LiveLocationSharingViewerViewModelType.Context { get }
}
@@ -0,0 +1,63 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
@available(iOS 14.0, *)
enum MockLiveLocationSharingViewerScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case currentUser
case multipleUsers
/// The associated screen
var screenType: Any.Type {
LiveLocationSharingViewer.self
}
/// A list of screen state definitions
static var allCases: [MockLiveLocationSharingViewerScreenState] {
return [.currentUser, .multipleUsers]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let service: LiveLocationSharingViewerServiceProtocol
switch self {
case .currentUser:
service = MockLiveLocationSharingViewerService()
case .multipleUsers:
service = MockLiveLocationSharingViewerService(generateRandomUsers: true)
}
let mapStyleURL = URL(string: "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx")!
let viewModel = LiveLocationSharingViewerViewModel(mapStyleURL: mapStyleURL, service: service)
// can simulate service and viewModel actions here if needs be.
return (
[service, viewModel],
AnyView(LiveLocationSharingViewer(viewModel: viewModel.context))
)
}
}
@@ -0,0 +1,27 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Combine
import CoreLocation
@available(iOS 14.0, *)
protocol LiveLocationSharingViewerServiceProtocol {
var usersLiveLocation: [UserLiveLocation] { get }
func isCurrentUserId(_ userId: String) -> Bool
}
@@ -0,0 +1,42 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import CoreLocation
@available(iOS 14.0, *)
class LiveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol {
// MARK: - Properties
private(set) var usersLiveLocation: [UserLiveLocation] = []
// MARK: Private
private let session: MXSession
// MARK: Public
func isCurrentUserId(_ userId: String) -> Bool {
return false
}
// MARK: - Setup
init(session: MXSession) {
self.session = session
}
}
@@ -0,0 +1,93 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Combine
import CoreLocation
@available(iOS 14.0, *)
class MockLiveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol {
private(set) var usersLiveLocation: [UserLiveLocation] = []
func isCurrentUserId(_ userId: String) -> Bool {
return "@alice:matrix.org" == userId
}
init(generateRandomUsers: Bool = false) {
let firstUserLiveLocation = self.createFirstUserLiveLocation()
let secondUserLiveLocation = self.createSecondUserLiveLocation()
var usersLiveLocation: [UserLiveLocation] = [firstUserLiveLocation, secondUserLiveLocation]
if generateRandomUsers {
for _ in 1...20 {
let randomUser = self.createRandomUserLiveLocation()
usersLiveLocation.append(randomUser)
}
}
self.usersLiveLocation = usersLiveLocation
}
private func createFirstUserLiveLocation() -> UserLiveLocation {
let userAvatarData = AvatarInput(mxContentUri: nil, matrixItemId: "@alice:matrix.org", displayName: "Alice")
let userCoordinate = CLLocationCoordinate2D(latitude: 51.4932641, longitude: -0.257096)
let currentTimeInterval = Date().timeIntervalSince1970
let timestamp = currentTimeInterval - 300
let timeout: TimeInterval = 800
let lastUpdate = currentTimeInterval - 100
return UserLiveLocation(avatarData: userAvatarData, timestamp: timestamp, timeout: timeout, lastUpdate: lastUpdate, coordinate: userCoordinate)
}
private func createSecondUserLiveLocation() -> UserLiveLocation {
let userAvatarData = AvatarInput(mxContentUri: nil, matrixItemId: "@bob:matrix.org", displayName: "Bob")
let coordinate = CLLocationCoordinate2D(latitude: 51.4952641, longitude: -0.259096)
let currentTimeInterval = Date().timeIntervalSince1970
let timestamp = currentTimeInterval - 600
let timeout: TimeInterval = 1200
let lastUpdate = currentTimeInterval - 300
return UserLiveLocation(avatarData: userAvatarData, timestamp: timestamp, timeout: timeout, lastUpdate: lastUpdate, coordinate: coordinate)
}
private func createRandomUserLiveLocation() -> UserLiveLocation {
let uuidString = UUID().uuidString.suffix(8)
let random = Double.random(in: 0.005...0.010)
let userAvatarData = AvatarInput(mxContentUri: nil, matrixItemId: "@user_\(uuidString):matrix.org", displayName: "User \(uuidString)")
let coordinate = CLLocationCoordinate2D(latitude: 51.4952641 + random, longitude: -0.259096 + random)
let currentTimeInterval = Date().timeIntervalSince1970
let timestamp = currentTimeInterval - 600
let timeout: TimeInterval = 1200
let lastUpdate = currentTimeInterval - 300
return UserLiveLocation(avatarData: userAvatarData, timestamp: timestamp, timeout: timeout, lastUpdate: lastUpdate, coordinate: coordinate)
}
}
@@ -0,0 +1,43 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import CoreLocation
/// Represents user live location
struct UserLiveLocation {
var userId: String {
return avatarData.matrixItemId
}
var displayName: String {
return avatarData.displayName ?? self.userId
}
let avatarData: AvatarInputProtocol
/// Location sharing start date
let timestamp: TimeInterval
/// Sharing duration from the start sharing date
let timeout: TimeInterval
/// Last coordinatore update date
let lastUpdate: TimeInterval
let coordinate: CLLocationCoordinate2D
}
@@ -0,0 +1,26 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
import RiotSwiftUI
@available(iOS 14.0, *)
class LiveLocationSharingViewerUITests: MockScreenTest {
override class var screenType: MockScreenState.Type {
return MockLiveLocationSharingViewerScreenState.self
}
}
@@ -0,0 +1,35 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
import Combine
@testable import RiotSwiftUI
@available(iOS 14.0, *)
class LiveLocationSharingViewerViewModelTests: XCTestCase {
var service: MockLiveLocationSharingViewerService!
var viewModel: LiveLocationSharingViewerViewModelProtocol!
var context: LiveLocationSharingViewerViewModelType.Context!
var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
service = MockLiveLocationSharingViewerService()
viewModel = LiveLocationSharingViewerViewModel(mapStyleURL: BuildSettings.tileServerMapStyleURL, service: service)
context = viewModel.context
}
}
@@ -0,0 +1,192 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
@available(iOS 14.0, *)
struct LiveLocationListItem: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
let viewData: LiveLocationListItemViewData
var timeoutText: String {
let timeLeftString: String
if let elapsedTimeString = self.elapsedTimeString(from: viewData.expirationDate, isPastDate: false) {
timeLeftString = VectorL10n.locationSharingLiveListItemTimeLeft(elapsedTimeString)
} else {
timeLeftString = VectorL10n.locationSharingLiveListItemSharingExpired
}
return timeLeftString
}
var lastUpdateText: String {
let timeLeftString: String
if let elapsedTimeString = self.elapsedTimeString(from: viewData.lastUpdate, isPastDate: true) {
timeLeftString = VectorL10n.locationSharingLiveListItemLastUpdate(elapsedTimeString)
} else {
timeLeftString = VectorL10n.locationSharingLiveListItemLastUpdateInvalid
}
return timeLeftString
}
var displayName: String {
return viewData.isCurrentUser ? VectorL10n.locationSharingLiveListItemCurrentUserDisplayName : viewData.displayName
}
var onStopSharingAction: (() -> (Void))? = nil
var onBackgroundTap: ((String) -> (Void))? = nil
// MARK: - Body
var body: some View {
HStack {
HStack(spacing: 18) {
AvatarImage(avatarData: viewData.avatarData, size: .medium)
.border()
VStack(alignment: .leading, spacing: 2) { Text(displayName)
.font(theme.fonts.bodySB)
.foregroundColor(theme.colors.primaryContent)
Text(timeoutText)
.font(theme.fonts.caption1)
.foregroundColor(theme.colors.primaryContent)
Text(lastUpdateText)
.font(theme.fonts.caption1)
.foregroundColor(theme.colors.secondaryContent)
}
}
if viewData.isCurrentUser {
Spacer()
Button(VectorL10n.locationSharingLiveListItemStopSharingAction) {
onStopSharingAction?()
}
.font(theme.fonts.caption1)
.foregroundColor(theme.colors.alert)
}
}
.onTapGesture {
onBackgroundTap?(self.viewData.userId)
}
}
// MARK: - Private
private func elapsedTimeString(from timestamp: TimeInterval, isPastDate: Bool) -> String? {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .abbreviated
formatter.allowedUnits = [.hour, .minute]
let date = Date(timeIntervalSince1970: timestamp)
let elaspedTimeinterval = date.timeIntervalSinceNow
var timeLeftString: String?
// Negative value indicate that the timestamp is in the past
// Positive value indicate that the timestamp is in the future
// Return nil if the sign is not the one as expected
if (isPastDate && elaspedTimeinterval <= 0) || (!isPastDate && elaspedTimeinterval >= 0) {
timeLeftString = formatter.string(from: abs(elaspedTimeinterval))
}
return timeLeftString
}
}
@available(iOS 14.0, *)
struct LiveLocationListPreview: View {
let liveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol = MockLiveLocationSharingViewerService()
var viewDataList: [LiveLocationListItemViewData] {
return self.listItemsViewData(from: liveLocationSharingViewerService.usersLiveLocation)
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
ForEach(viewDataList) { viewData in
LiveLocationListItem(viewData: viewData, onStopSharingAction: {
}, onBackgroundTap: { userId in
})
}
Spacer()
}
.padding()
}
private func listItemsViewData(from usersLiveLocation: [UserLiveLocation]) -> [LiveLocationListItemViewData] {
var listItemsViewData: [LiveLocationListItemViewData] = []
let sortedUsersLiveLocation = usersLiveLocation.sorted { userLiveLocation1, userLiveLocation2 in
return userLiveLocation1.displayName > userLiveLocation2.displayName
}
listItemsViewData = sortedUsersLiveLocation.map({ userLiveLocation in
return self.listItemViewData(from: userLiveLocation)
})
let currentUserIndex = listItemsViewData.firstIndex { viewData in
return viewData.isCurrentUser
}
// Move current user as first item
if let currentUserIndex = currentUserIndex {
let currentUserViewData = listItemsViewData[currentUserIndex]
listItemsViewData.remove(at: currentUserIndex)
listItemsViewData.insert(currentUserViewData, at: 0)
}
return listItemsViewData
}
private func listItemViewData(from userLiveLocation: UserLiveLocation) -> LiveLocationListItemViewData {
let isCurrentUser = self.liveLocationSharingViewerService.isCurrentUserId(userLiveLocation.userId)
let expirationDate = userLiveLocation.timestamp + userLiveLocation.timeout
return LiveLocationListItemViewData(userId: userLiveLocation.userId, isCurrentUser: isCurrentUser, avatarData: userLiveLocation.avatarData, displayName: userLiveLocation.displayName, expirationDate: expirationDate, lastUpdate: userLiveLocation.lastUpdate)
}
}
@available(iOS 14.0, *)
struct LiveLocationListItem_Previews: PreviewProvider {
static var previews: some View {
Group {
LiveLocationListPreview().theme(.light).preferredColorScheme(.light)
LiveLocationListPreview().theme(.dark).preferredColorScheme(.dark)
}
}
}
@@ -0,0 +1,39 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
/// View data for LiveLocationListItem
struct LiveLocationListItemViewData: Identifiable {
var id: String {
return userId
}
let userId: String
let isCurrentUser: Bool
let avatarData: AvatarInputProtocol
let displayName: String
/// The location sharing expiration date
let expirationDate: TimeInterval
/// Last coordinatore update
let lastUpdate: TimeInterval
}
@@ -0,0 +1,124 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
import DSBottomSheet
@available(iOS 14.0, *)
struct LiveLocationSharingViewer: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
var isBottomSheetVisible = true
@State private var isBottomSheetExpanded = false
// MARK: Public
@ObservedObject var viewModel: LiveLocationSharingViewerViewModel.Context
var body: some View {
ZStack(alignment: .bottom) {
LocationSharingMapView(tileServerMapURL: viewModel.viewState.mapStyleURL,
annotations: viewModel.viewState.annotations,
highlightedAnnotation: viewModel.viewState.highlightedAnnotation,
userAvatarData: nil,
showsUserLocation: false,
userAnnotationCanShowCallout: true,
userLocation: Binding.constant(nil),
mapCenterCoordinate: Binding.constant(nil),
onCalloutTap: { annotation in
if let userLocationAnnotation = annotation as? UserLocationAnnotation {
viewModel.send(viewAction: .share(userLocationAnnotation))
}
},
errorSubject: viewModel.viewState.errorSubject)
VStack(alignment: .center) {
Spacer()
MapCreditsView()
.offset(y: -130)
}
}
.navigationTitle(VectorL10n.locationSharingLiveViewerTitle)
.accentColor(theme.colors.accent)
.bottomSheet(sheet, if: isBottomSheetVisible)
.alert(item: $viewModel.alertInfo) { info in
info.alert
}
.activityIndicator(show: viewModel.viewState.showLoadingIndicator)
}
var userLocationList: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
ForEach(viewModel.viewState.listItemsViewData) { viewData in
LiveLocationListItem(viewData: viewData, onStopSharingAction: {
viewModel.send(viewAction: .stopSharing)
}, onBackgroundTap: { userId in
// Push bottom sheet down on item tap
isBottomSheetExpanded = false
viewModel.send(viewAction: .tapListItem(userId))
})
}
}
.padding()
}
}
}
// MARK: - Bottom sheet
@available(iOS 14.0, *)
extension LiveLocationSharingViewer {
var sheetStyle: BottomSheetStyle {
var bottomSheetStyle = BottomSheetStyle.standard
bottomSheetStyle.snapRatio = 0.16
let backgroundColor = theme.colors.background
let handleStyle = BottomSheetHandleStyle(backgroundColor: backgroundColor, dividerColor: backgroundColor)
bottomSheetStyle.handleStyle = handleStyle
return bottomSheetStyle
}
var sheet: some BottomSheetView {
BottomSheet(
isExpanded: $isBottomSheetExpanded,
minHeight: .points(150),
maxHeight: .available,
style: sheetStyle) {
userLocationList
}
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct LiveLocationSharingViewer_Previews: PreviewProvider {
static let stateRenderer = MockLiveLocationSharingViewerScreenState.stateRenderer
static var previews: some View {
Group {
stateRenderer.screenGroup().theme(.light).preferredColorScheme(.light)
stateRenderer.screenGroup().theme(.dark).preferredColorScheme(.dark)
}
}
}
@@ -17,26 +17,36 @@
import Foundation
import Mapbox
/// Base class to handle a map annotation
class LocationAnnotation: NSObject, MGLAnnotation {
// MARK: - Properties
// Title property is needed to enable annotation selection and callout view showing
var title: String?
let coordinate: CLLocationCoordinate2D
// MARK: - Setup
init(coordinate: CLLocationCoordinate2D) {
self.coordinate = coordinate
super.init()
}
}
/// POI map annotation
class PinLocationAnnotation: LocationAnnotation {}
/// User map annotation
class UserLocationAnnotation: LocationAnnotation {
// MARK: - Properties
var userId: String {
return avatarData.matrixItemId
}
let avatarData: AvatarInputProtocol
// MARK: - Setup
@@ -45,7 +55,8 @@ class UserLocationAnnotation: LocationAnnotation {
coordinate: CLLocationCoordinate2D) {
self.avatarData = avatarData
super.init(coordinate: coordinate)
super.title = self.avatarData.displayName ?? self.userId
}
}
@@ -18,7 +18,7 @@ import Foundation
struct MapViewErrorAlertInfoBuilder {
func build(with error: LocationSharingViewError, dimissalCallback: (() -> Void)?) -> AlertInfo<LocationSharingAlertType>? {
func build(with error: LocationSharingViewError, primaryButtonCompletion: (() -> Void)?) -> AlertInfo<LocationSharingAlertType>? {
let alertInfo: AlertInfo<LocationSharingAlertType>?
@@ -26,20 +26,16 @@ struct MapViewErrorAlertInfoBuilder {
case .failedLoadingMap:
alertInfo = AlertInfo(id: .mapLoadingError,
title: VectorL10n.locationSharingLoadingMapErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.ok, dimissalCallback))
primaryButton: (VectorL10n.ok, primaryButtonCompletion))
case .failedLocatingUser:
alertInfo = AlertInfo(id: .userLocatingError,
title: VectorL10n.locationSharingLocatingUserErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.ok, dimissalCallback))
primaryButton: (VectorL10n.ok, primaryButtonCompletion))
case .invalidLocationAuthorization:
alertInfo = AlertInfo(id: .authorizationError,
title: VectorL10n.locationSharingInvalidAuthorizationErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.locationSharingInvalidAuthorizationNotNow, dimissalCallback),
secondaryButton: (VectorL10n.locationSharingInvalidAuthorizationSettings, {
if let applicationSettingsURL = URL(string:UIApplication.openSettingsURLString) {
UIApplication.shared.open(applicationSettingsURL)
}
}))
primaryButton: (VectorL10n.locationSharingInvalidAuthorizationNotNow, primaryButtonCompletion),
secondaryButton: (VectorL10n.locationSharingInvalidAuthorizationSettings, primaryButtonCompletion))
default:
alertInfo = nil
}
@@ -48,4 +44,3 @@ struct MapViewErrorAlertInfoBuilder {
}
}
@@ -43,6 +43,9 @@ struct LocationSharingMapView: UIViewRepresentable {
/// True to indicate to show and follow current user location
var showsUserLocation: Bool = false
/// True to indicate that a touch on user annotation can show a callout
var userAnnotationCanShowCallout: Bool = false
/// Last user location if `showsUserLocation` has been enabled
@Binding var userLocation: CLLocationCoordinate2D?
@@ -50,6 +53,9 @@ struct LocationSharingMapView: UIViewRepresentable {
/// Coordinate of the center of the map
@Binding var mapCenterCoordinate: CLLocationCoordinate2D?
/// Called when an annotation callout view is tapped
var onCalloutTap: ((MGLAnnotation) -> Void)?
/// Publish view errors if any
let errorSubject: PassthroughSubject<LocationSharingViewError, Never>
@@ -160,6 +166,27 @@ extension LocationSharingMapView {
}
locationSharingMapView.mapCenterCoordinate = mapCenterCoordinate
}
// MARK: Callout
func mapView(_ mapView: MGLMapView, annotationCanShowCallout annotation: MGLAnnotation) -> Bool {
return annotation is UserLocationAnnotation && locationSharingMapView.userAnnotationCanShowCallout
}
func mapView(_ mapView: MGLMapView, calloutViewFor annotation: MGLAnnotation) -> MGLCalloutView? {
if let userLocationAnnotation = annotation as? UserLocationAnnotation {
return UserAnnotationCalloutView(userLocationAnnotation: userLocationAnnotation)
}
return nil
}
func mapView(_ mapView: MGLMapView, tapOnCalloutFor annotation: MGLAnnotation) {
locationSharingMapView.onCalloutTap?(annotation)
// Hide the callout
mapView.deselectAnnotation(annotation, animated: true)
}
}
}
@@ -40,7 +40,6 @@ struct LocationSharingMarkerView<Content: View>: View {
markerImage
.frame(width: 40, height: 40)
}
.offset(x: 0, y: -23)
}
}
@@ -0,0 +1,85 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Reusable
class UserAnnotationCalloutContentView: UIView, Themable, NibLoadable {
// MARK: - Constants
private static let sizingView = UserAnnotationCalloutContentView.instantiate()
private enum Constants {
static let height: CGFloat = 44.0
static let cornerRadius: CGFloat = 8.0
}
// MARK: - Properties
// MARK: Outlets
@IBOutlet var backgroundView: UIView!
@IBOutlet var titleLabel: UILabel!
@IBOutlet var shareButton: UIButton!
// MARK: - Setup
static func instantiate() -> UserAnnotationCalloutContentView {
return UserAnnotationCalloutContentView.loadFromNib()
}
// MARK: - Public
func update(theme: Theme) {
self.backgroundView.backgroundColor = theme.colors.background
self.titleLabel.textColor = theme.colors.secondaryContent
self.titleLabel.font = theme.fonts.callout
self.shareButton.tintColor = theme.colors.secondaryContent
}
// MARK: - Life cycle
override func awakeFromNib() {
super.awakeFromNib()
self.titleLabel.text = VectorL10n.locationSharingLiveMapCalloutTitle
self.backgroundView.layer.masksToBounds = true
}
override func layoutSubviews() {
super.layoutSubviews()
self.backgroundView.layer.cornerRadius = Constants.cornerRadius
}
static func contentViewSize() -> CGSize {
let sizingView = self.sizingView
sizingView.frame = CGRect(x: 0, y: 0, width: 1, height: Constants.height)
sizingView.setNeedsLayout()
sizingView.layoutIfNeeded()
let fittingSize = CGSize(width: UIView.layoutFittingCompressedSize.width, height: Constants.height)
let size = sizingView.systemLayoutSizeFitting(fittingSize,
withHorizontalFittingPriority: .fittingSizeLevel,
verticalFittingPriority: .required)
return size
}
}
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="iN0-l3-epB" customClass="UserAnnotationCalloutContentView" customModule="Riot" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="222" height="62"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="WEX-LH-zYE">
<rect key="frame" x="0.0" y="0.0" width="222" height="62"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="8qV-yr-fDH">
<rect key="frame" x="10" y="0.0" width="173" height="62"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="252" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Htx-uD-cf2">
<rect key="frame" x="188" y="0.0" width="24" height="62"/>
<inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/>
<state key="normal" image="share_action_button"/>
</button>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="trailing" secondItem="WEX-LH-zYE" secondAttribute="trailing" id="3Rb-Vi-QNG"/>
<constraint firstItem="Htx-uD-cf2" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="WEX-LH-zYE" secondAttribute="bottom" id="8oX-y9-FA8"/>
<constraint firstItem="8qV-yr-fDH" firstAttribute="top" secondItem="WEX-LH-zYE" secondAttribute="top" id="Cqh-a4-3xH"/>
<constraint firstItem="WEX-LH-zYE" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="Glo-Kv-b9P"/>
<constraint firstAttribute="bottom" secondItem="WEX-LH-zYE" secondAttribute="bottom" id="TVK-pw-MQi"/>
<constraint firstItem="Htx-uD-cf2" firstAttribute="centerY" secondItem="8qV-yr-fDH" secondAttribute="centerY" id="VLm-I1-Xa5"/>
<constraint firstItem="Htx-uD-cf2" firstAttribute="leading" secondItem="8qV-yr-fDH" secondAttribute="trailing" constant="5" id="aNO-Wu-hrO"/>
<constraint firstItem="Htx-uD-cf2" firstAttribute="trailing" secondItem="WEX-LH-zYE" secondAttribute="trailing" constant="-10" id="eB7-OT-FSZ"/>
<constraint firstItem="Htx-uD-cf2" firstAttribute="top" relation="greaterThanOrEqual" secondItem="WEX-LH-zYE" secondAttribute="top" id="gsk-Ld-Ahq"/>
<constraint firstItem="WEX-LH-zYE" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" id="kVf-HO-jBq"/>
<constraint firstItem="8qV-yr-fDH" firstAttribute="leading" secondItem="WEX-LH-zYE" secondAttribute="leading" constant="10" id="nDk-Sm-zM4"/>
<constraint firstItem="8qV-yr-fDH" firstAttribute="bottom" secondItem="WEX-LH-zYE" secondAttribute="bottom" id="ypH-Wk-ly7"/>
</constraints>
<nil key="simulatedTopBarMetrics"/>
<nil key="simulatedBottomBarMetrics"/>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<connections>
<outlet property="backgroundView" destination="WEX-LH-zYE" id="G7V-Kl-xz0"/>
<outlet property="shareButton" destination="Htx-uD-cf2" id="crB-EP-vHO"/>
<outlet property="titleLabel" destination="8qV-yr-fDH" id="9o1-jU-nR6"/>
</connections>
<point key="canvasLocation" x="-7.2463768115942031" y="-193.52678571428569"/>
</view>
</objects>
<resources>
<image name="share_action_button" width="24" height="24"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
@@ -0,0 +1,156 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Mapbox
class UserAnnotationCalloutView: UIView, MGLCalloutView, Themable {
// MARK: - Constants
private enum Constants {
static let animationDuration: TimeInterval = 0.2
static let bottomMargin: CGFloat = 3.0
}
// MARK: - Properties
// MARK: Overrides
var representedObject: MGLAnnotation
lazy var leftAccessoryView: UIView = UIView()
lazy var rightAccessoryView: UIView = UIView()
var delegate: MGLCalloutViewDelegate?
// Allow the callout to remain open during panning.
let dismissesAutomatically: Bool = false
let isAnchoredToAnnotation: Bool = true
// https://github.com/mapbox/mapbox-gl-native/issues/9228
override var center: CGPoint {
set {
var newCenter = newValue
newCenter.y -= bounds.midY + Constants.bottomMargin
super.center = newCenter
}
get {
return super.center
}
}
// MARK: Private
lazy var contentView: UserAnnotationCalloutContentView = {
return UserAnnotationCalloutContentView.instantiate()
}()
// MARK: - Setup
required init(userLocationAnnotation: UserLocationAnnotation) {
self.representedObject = userLocationAnnotation
super.init(frame: .zero)
self.vc_addSubViewMatchingParent(self.contentView)
self.update(theme: ThemeService.shared().theme)
let size = UserAnnotationCalloutContentView.contentViewSize()
self.frame = CGRect(origin: .zero, size: size)
}
required init?(coder decoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Public
func update(theme: Theme) {
self.contentView.update(theme: theme)
}
// MARK: - Overrides
func presentCallout(from rect: CGRect, in view: UIView, constrainedTo constrainedRect: CGRect, animated: Bool) {
// Set callout above the marker view
self.center = view.center.applying(CGAffineTransform(translationX: 0, y: view.bounds.height/2 + self.bounds.height))
delegate?.calloutViewWillAppear?(self)
view.addSubview(self)
if isCalloutTappable() {
// Handle taps and eventually try to send them to the delegate (usually the map view).
self.contentView.shareButton.addTarget(self, action: #selector(UserAnnotationCalloutView.calloutTapped), for: .touchUpInside)
} else {
// Disable tapping and highlighting.
self.contentView.shareButton.isUserInteractionEnabled = false
}
if animated {
alpha = 0
UIView.animate(withDuration: Constants.animationDuration) { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.alpha = 1
strongSelf.delegate?.calloutViewDidAppear?(strongSelf)
}
} else {
delegate?.calloutViewDidAppear?(self)
}
}
func dismissCallout(animated: Bool) {
if (superview != nil) {
if animated {
UIView.animate(withDuration: Constants.animationDuration, animations: { [weak self] in
self?.alpha = 0
}, completion: { [weak self] _ in
self?.removeFromSuperview()
})
} else {
removeFromSuperview()
}
}
}
// MARK: - Callout interaction handlers
func isCalloutTappable() -> Bool {
if let delegate = delegate {
if delegate.responds(to: #selector(MGLCalloutViewDelegate.calloutViewShouldHighlight)) {
return delegate.calloutViewShouldHighlight!(self)
}
}
return false
}
@objc func calloutTapped() {
if isCalloutTappable() && delegate!.responds(to: #selector(MGLCalloutViewDelegate.calloutViewTapped)) {
delegate!.calloutViewTapped!(self)
}
}
}
@@ -21,30 +21,41 @@ import Mapbox
@available(iOS 14, *)
class LocationAnnotationView: MGLUserLocationAnnotationView {
// MARK: - Constants
private enum Constants {
static let defaultFrame = CGRect(x: 0, y: 0, width: 46, height: 46)
}
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: - Setup
init(avatarData: AvatarInputProtocol) {
super.init(frame: .zero)
override init(annotation: MGLAnnotation?, reuseIdentifier: String?) {
super.init(annotation: annotation, reuseIdentifier:
reuseIdentifier)
self.frame = Constants.defaultFrame
}
convenience init(avatarData: AvatarInputProtocol) {
self.init(annotation: nil, reuseIdentifier: nil)
self.addUserMarkerView(with: avatarData)
}
init(userLocationAnnotation: UserLocationAnnotation) {
convenience init(userLocationAnnotation: UserLocationAnnotation) {
// TODO: Use a reuseIdentifier
super.init(annotation: userLocationAnnotation, reuseIdentifier: nil)
self.init(annotation: userLocationAnnotation, reuseIdentifier: nil)
self.addUserMarkerView(with: userLocationAnnotation.avatarData)
}
init(pinLocationAnnotation: PinLocationAnnotation) {
convenience init(pinLocationAnnotation: PinLocationAnnotation) {
// TODO: Use a reuseIdentifier
super.init(annotation: pinLocationAnnotation, reuseIdentifier: nil)
self.init(annotation: pinLocationAnnotation, reuseIdentifier: nil)
self.addPinMarkerView()
}
@@ -57,32 +68,34 @@ class LocationAnnotationView: MGLUserLocationAnnotationView {
private func addUserMarkerView(with avatarData: AvatarInputProtocol) {
guard let avatarImageView = UIHostingController(rootView: LocationSharingMarkerView(backgroundColor: theme.userColor(for: avatarData.matrixItemId)) {
guard let avatarMarkerView = UIHostingController(rootView: LocationSharingMarkerView(backgroundColor: theme.userColor(for: avatarData.matrixItemId)) {
AvatarImage(avatarData: avatarData, size: .medium)
.border()
}).view else {
return
}
addMarkerView(with: avatarImageView)
addMarkerView(avatarMarkerView)
}
private func addPinMarkerView() {
guard let pinImageView = UIHostingController(rootView: LocationSharingMarkerView(backgroundColor: theme.colors.accent) {
guard let pinMarkerView = UIHostingController(rootView: LocationSharingMarkerView(backgroundColor: theme.colors.accent) {
Image(uiImage: Asset.Images.locationPinIcon.image)
.resizable()
.shapedBorder(color: theme.colors.accent, borderWidth: 3, shape: Circle())
}).view else {
return
}
addMarkerView(with: pinImageView)
addMarkerView(pinMarkerView)
}
private func addMarkerView(with imageView: UIView) {
addSubview(imageView)
private func addMarkerView(_ markerView: UIView) {
addConstraints([topAnchor.constraint(equalTo: imageView.topAnchor),
leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
trailingAnchor.constraint(equalTo: imageView.trailingAnchor)])
markerView.backgroundColor = .clear
addSubview(markerView)
markerView.frame = self.bounds
}
}
@@ -19,17 +19,5 @@ import RiotSwiftUI
@available(iOS 14.0, *)
class RoomAccessTypeChooserUITests: MockScreenTest {
override class var screenType: MockScreenState.Type {
return MockRoomAccessTypeChooserScreenState.self
}
override class func createTest() -> MockScreenTest {
return RoomAccessTypeChooserUITests(selector: #selector(verifyRoomAccessTypeChooserScreen))
}
func verifyRoomAccessTypeChooserScreen() throws {
guard let screenState = screenState as? MockRoomAccessTypeChooserScreenState else { fatalError("no screen") }
}
// Tests to be implemented.
}
@@ -19,17 +19,5 @@ import RiotSwiftUI
@available(iOS 14.0, *)
class RoomUpgradeUITests: MockScreenTest {
override class var screenType: MockScreenState.Type {
return MockRoomUpgradeScreenState.self
}
override class func createTest() -> MockScreenTest {
return RoomUpgradeUITests(selector: #selector(verifyRoomUpgradeScreen))
}
func verifyRoomUpgradeScreen() throws {
guard let screenState = screenState as? MockRoomUpgradeScreenState else { fatalError("no screen") }
}
// Tests to be implemented.
}
@@ -78,7 +78,17 @@ class StaticLocationViewingViewModel: StaticLocationViewingViewModelType, Static
}
let alertInfo = mapViewErrorAlertInfoBuilder.build(with: error) { [weak self] in
self?.completion?(.close)
switch error {
case .invalidLocationAuthorization:
if let applicationSettingsURL = URL(string:UIApplication.openSettingsURLString) {
UIApplication.shared.open(applicationSettingsURL)
} else {
self?.completion?(.close)
}
default:
self?.completion?(.close)
}
}
state.bindings.alertInfo = alertInfo
@@ -19,21 +19,28 @@ import RiotSwiftUI
@available(iOS 14.0, *)
class StaticLocationViewingUITests: MockScreenTest {
private var app: XCUIApplication!
override func setUp() {
continueAfterFailure = false
app = XCUIApplication()
app.launch()
override class var screenType: MockScreenState.Type {
return MockStaticLocationViewingScreenState.self
}
override class func createTest() -> MockScreenTest {
return StaticLocationViewingUITests(selector: #selector(verifyStaticLocationViewingScreen))
}
func verifyStaticLocationViewingScreen() {
guard let screenState = screenState as? MockStaticLocationViewingScreenState else { fatalError("no screen") }
switch screenState {
case .showUserLocation:
verifyInitialExistingLocation()
case .showPinLocation:
verifyInitialExistingLocation()
}
}
func testInitialExistingLocation() {
goToScreenWithIdentifier(MockStaticLocationViewingScreenState.showUserLocation.title)
XCTAssertTrue(app.buttons["Cancel"].exists)
XCTAssertTrue(app.buttons["StaticLocationView.shareButton"].exists)
XCTAssertTrue(app.otherElements["Map"].exists)
func verifyInitialExistingLocation() {
XCTAssertTrue(app.buttons["Cancel"].exists, "The cancel button should exist.")
XCTAssertTrue(app.buttons["shareButton"].exists, "The share button should exist.")
}
}
@@ -61,9 +61,9 @@ struct StaticLocationView: View {
viewModel.send(viewAction: .share)
} label: {
Image(uiImage: Asset.Images.locationShareIcon.image)
.accessibilityIdentifier("LocationSharingView.shareButton")
}
.disabled(!viewModel.viewState.shareButtonEnabled)
.accessibilityIdentifier("shareButton")
}
}
.navigationBarTitleDisplayMode(.inline)