diff --git a/.github/workflows/ci-ui-tests.yml b/.github/workflows/ci-ui-tests.yml new file mode 100644 index 000000000..eb2dde14d --- /dev/null +++ b/.github/workflows/ci-ui-tests.yml @@ -0,0 +1,57 @@ +name: UI Tests CI + +on: + # Triggers the workflow on any pull request + pull_request: + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +env: + # Make the git branch for a PR available to our Fastfile + MX_GIT_BRANCH: ${{ github.event.pull_request.head.ref }} + +jobs: + tests: + name: UI Tests + runs-on: macos-11 + + concurrency: + # Only allow a single run of this workflow on each branch, automatically cancelling older runs. + group: ui-tests-${{ github.head_ref }} + cancel-in-progress: true + + steps: + - uses: actions/checkout@v2 + + # Common cache + # Note: GH actions do not support yaml anchor yet. We need to duplicate this for every job + - uses: actions/cache@v2 + with: + path: Pods + key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} + restore-keys: | + ${{ runner.os }}-pods- + - uses: actions/cache@v2 + with: + path: vendor/bundle + key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: | + ${{ runner.os }}-gems- + + # Make sure we use the latest version of MatrixSDK + - name: Reset MatrixSDK pod + run: rm -rf Pods/MatrixSDK + + # Common setup + # Note: GH actions do not support yaml anchor yet. We need to duplicate this for every job + - name: Bundle install + run: | + bundle config path vendor/bundle + bundle install --jobs 4 --retry 3 + - name: Use right MatrixSDK versions + run: bundle exec fastlane point_dependencies_to_related_branches + + # Main step + - name: Unit tests + run: bundle exec fastlane uitest diff --git a/.github/workflows/release-alpha.yml b/.github/workflows/release-alpha.yml index c952f6a4a..acdfa1b1d 100644 --- a/.github/workflows/release-alpha.yml +++ b/.github/workflows/release-alpha.yml @@ -25,7 +25,7 @@ jobs: if: "${{ env.P12_KEY != '' || env.P12_PASSWORD_KEY != '' }}" run: echo "::set-output name=defined::true" build: - # Run job if secrets are avilable (not avaiable for forks). + # Run job if secrets are available (not available for forks). needs: [check-secret] if: needs.check-secret.outputs.out-key == 'true' name: Release @@ -84,7 +84,7 @@ jobs: - name: Build Ad-hoc release and send it to Diawi run: bundle exec fastlane alpha env: - # Automaticaly bypass 2FA upgrade if possible on Apple account. + # Automatically bypass 2FA upgrade if possible on Apple account. SPACESHIP_SKIP_2FA_UPGRADE: true APPLE_ID: ${{ secrets.FASTLANE_USER }} FASTLANE_USER: ${{ secrets.FASTLANE_USER }} diff --git a/.github/workflows/triage-move-labelled.yml b/.github/workflows/triage-move-labelled.yml index d1e110540..994a13941 100644 --- a/.github/workflows/triage-move-labelled.yml +++ b/.github/workflows/triage-move-labelled.yml @@ -11,7 +11,6 @@ jobs: if: > contains(github.event.issue.labels.*.name, 'A-Maths') || contains(github.event.issue.labels.*.name, 'A-Message-Pinning') || - contains(github.event.issue.labels.*.name, 'A-Threads') || contains(github.event.issue.labels.*.name, 'A-Polls') || contains(github.event.issue.labels.*.name, 'A-Location-Sharing') || contains(github.event.issue.labels.*.name, 'A-Message-Bubbles') || diff --git a/Config/Project.xcconfig b/Config/Project.xcconfig index 331f7b44f..95b6295d6 100644 --- a/Config/Project.xcconfig +++ b/Config/Project.xcconfig @@ -25,10 +25,10 @@ KEYCHAIN_ACCESS_GROUP = $(AppIdentifierPrefix)$(BASE_BUNDLE_IDENTIFIER).keychain.shared // Build settings -IPHONEOS_DEPLOYMENT_TARGET = 12.1 +IPHONEOS_DEPLOYMENT_TARGET = 14.0 SDKROOT = iphoneos TARGETED_DEVICE_FAMILY = 1,2 -SWIFT_VERSION = 5.3.1 +SWIFT_VERSION = 5.6 ENABLE_BITCODE = NO LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks @executable_path/../../Frameworks diff --git a/Podfile b/Podfile index 8c6a75f18..1ab2e77df 100644 --- a/Podfile +++ b/Podfile @@ -1,7 +1,7 @@ source 'https://cdn.cocoapods.org/' # Uncomment this line to define a global platform for your project -platform :ios, '12.1' +platform :ios, '14.0' # Use frameworks to allow usage of pods written in Swift use_frameworks! @@ -57,6 +57,7 @@ end def import_SwiftUI_pods pod 'Introspect', '~> 0.1' + pod 'DSBottomSheet', '~> 0.3' end abstract_target 'RiotPods' do @@ -151,4 +152,4 @@ post_install do |installer| config.build_settings['OTHER_SWIFT_FLAGS'] ||= ['$(inherited)', '-Xcc', '-Wno-nullability-completeness'] end end -end \ No newline at end of file +end diff --git a/Podfile.lock b/Podfile.lock index 76519dbe5..1cc3bd2e1 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -20,6 +20,7 @@ PODS: - BlueRSA (1.0.200) - DGCollectionViewLeftAlignFlowLayout (1.0.4) - Down (0.11.0) + - DSBottomSheet (0.3.0) - DSWaveformImage (6.1.1) - DTCoreText (1.6.26): - DTCoreText/Core (= 1.6.26) @@ -105,6 +106,7 @@ DEPENDENCIES: - AnalyticsEvents (from `https://github.com/matrix-org/matrix-analytics-events.git`, branch `release/swift`) - DGCollectionViewLeftAlignFlowLayout (~> 1.0.4) - Down (~> 0.11.0) + - DSBottomSheet (~> 0.3) - DSWaveformImage (~> 6.1.1) - DTCoreText (~> 1.6.25) - ffmpeg-kit-ios-audio (= 4.5.1) @@ -139,6 +141,7 @@ SPEC REPOS: - BlueRSA - DGCollectionViewLeftAlignFlowLayout - Down + - DSBottomSheet - DSWaveformImage - DTCoreText - DTFoundation @@ -191,6 +194,7 @@ SPEC CHECKSUMS: BlueRSA: dfeef51db96bcc4edec654956c1581adbda4e6a3 DGCollectionViewLeftAlignFlowLayout: a0fa58797373ded039cafba8133e79373d048399 Down: b6ba1bc985c9d2f4e15e3b293d2207766fa12612 + DSBottomSheet: ca0ac37eb5af2dd54663f86b84382ed90a59be2a DSWaveformImage: 3c718a0cf99291887ee70d1d0c18d80101d3d9ce DTCoreText: ec749e013f2e1f76de5e7c7634642e600a7467ce DTFoundation: a53f8cda2489208cbc71c648be177f902ee17536 @@ -225,6 +229,6 @@ SPEC CHECKSUMS: zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 8055bae15b82bd29b1d12c64ff038fe6f86a8ca0 +PODFILE CHECKSUM: 39feedaf75b9a9287e4fe5309200e0493a9251a3 -COCOAPODS: 1.11.2 +COCOAPODS: 1.11.3 diff --git a/Riot/Assets/Images.xcassets/Common/share_action_button.imageset/Contents.json b/Riot/Assets/Images.xcassets/Common/share_action_button.imageset/Contents.json index a6bb96098..1f52d4d2c 100644 --- a/Riot/Assets/Images.xcassets/Common/share_action_button.imageset/Contents.json +++ b/Riot/Assets/Images.xcassets/Common/share_action_button.imageset/Contents.json @@ -19,5 +19,8 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } } diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 9c0a6f89a..b3ad37b6b 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -2143,7 +2143,13 @@ Tap the + to start adding people."; "location_sharing_static_share_title" = "Send my current location"; "location_sharing_pin_drop_share_title" = "Send this location"; "location_sharing_live_map_callout_title" = "Share location"; -"location_sharing_live_timer_outgoing" = "%@ left"; +"location_sharing_live_viewer_title" = "Location"; +"location_sharing_live_list_item_time_left" = "%@ left"; +"location_sharing_live_list_item_sharing_expired" = "Sharing expired"; +"location_sharing_live_list_item_last_update" = "Updated %@ ago"; +"location_sharing_live_list_item_last_update_invalid" = "Unknown last update"; +"location_sharing_live_list_item_current_user_display_name" = "You"; +"location_sharing_live_list_item_stop_sharing_action" = "Stop sharing"; "location_sharing_live_timer_incoming" = "Live until %@"; "location_sharing_live_loading" = "Loading Live location..."; "location_sharing_live_error" = "Live location error"; diff --git a/Riot/Assets/third_party_licenses.html b/Riot/Assets/third_party_licenses.html index 8d973a9a6..bfbc72922 100644 --- a/Riot/Assets/third_party_licenses.html +++ b/Riot/Assets/third_party_licenses.html @@ -1950,6 +1950,31 @@ Library. CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+
  • + DSBottomSheet (https://github.com/danielsaidi/BottomSheet) +

    + MIT License +

    + Copyright (c) 2021 Daniel Saidi +

    + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: +

    + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. +

    + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +

    +
  • diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 2c5e2c007..98cddc8cb 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -2767,6 +2767,30 @@ public class VectorL10n: NSObject { public static var locationSharingLiveError: String { return VectorL10n.tr("Vector", "location_sharing_live_error") } + /// You + public static var locationSharingLiveListItemCurrentUserDisplayName: String { + return VectorL10n.tr("Vector", "location_sharing_live_list_item_current_user_display_name") + } + /// Updated %@ ago + public static func locationSharingLiveListItemLastUpdate(_ p1: String) -> String { + return VectorL10n.tr("Vector", "location_sharing_live_list_item_last_update", p1) + } + /// Unknown last update + public static var locationSharingLiveListItemLastUpdateInvalid: String { + return VectorL10n.tr("Vector", "location_sharing_live_list_item_last_update_invalid") + } + /// Sharing expired + public static var locationSharingLiveListItemSharingExpired: String { + return VectorL10n.tr("Vector", "location_sharing_live_list_item_sharing_expired") + } + /// Stop sharing + public static var locationSharingLiveListItemStopSharingAction: String { + return VectorL10n.tr("Vector", "location_sharing_live_list_item_stop_sharing_action") + } + /// %@ left + public static func locationSharingLiveListItemTimeLeft(_ p1: String) -> String { + return VectorL10n.tr("Vector", "location_sharing_live_list_item_time_left", p1) + } /// Loading Live location... public static var locationSharingLiveLoading: String { return VectorL10n.tr("Vector", "location_sharing_live_loading") @@ -2783,9 +2807,9 @@ public class VectorL10n: NSObject { public static func locationSharingLiveTimerIncoming(_ p1: String) -> String { return VectorL10n.tr("Vector", "location_sharing_live_timer_incoming", p1) } - /// %@ left - public static func locationSharingLiveTimerOutgoing(_ p1: String) -> String { - return VectorL10n.tr("Vector", "location_sharing_live_timer_outgoing", p1) + /// Location + public static var locationSharingLiveViewerTitle: String { + return VectorL10n.tr("Vector", "location_sharing_live_viewer_title") } /// %@ could not load the map. Please try again later. public static func locationSharingLoadingMapErrorTitle(_ p1: String) -> String { @@ -8126,15 +8150,18 @@ public class VectorL10n: NSObject { extension VectorL10n { static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { - let format = NSLocalizedString(key, tableName: table, bundle: Bundle.app, comment: "") - let locale: Locale - if let providedLocale = LocaleProvider.locale { - locale = providedLocale - } else { - locale = Locale.current - } - - return String(format: format, locale: locale, arguments: args) + let format = NSLocalizedString(key, tableName: table, bundle: bundle, comment: "") + let locale = LocaleProvider.locale ?? Locale.current + return String(format: format, locale: locale, arguments: args) + } + /// The bundle to load strings from. This will be the app's bundle unless running + /// the UI tests target, in which case the strings are contained in the tests bundle. + static let bundle: Bundle = { + if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { + // The tests bundle is embedded inside a runner. Find the bundle for VectorL10n. + return Bundle(for: VectorL10n.self) } + return Bundle.app + }() } diff --git a/Riot/Managers/Theme/Theme.swift b/Riot/Managers/Theme/Theme.swift index 039175371..b204c16e2 100644 --- a/Riot/Managers/Theme/Theme.swift +++ b/Riot/Managers/Theme/Theme.swift @@ -108,7 +108,7 @@ import DesignKit var roomCellLocalisationTextColor: UIColor { get } - var roomCellLocalisationStartedColor: UIColor { get } + var roomCellLocalisationIconStartedColor: UIColor { get } var roomCellLocalisationEndedColor: UIColor { get } diff --git a/Riot/Managers/Theme/Themes/DarkTheme.swift b/Riot/Managers/Theme/Themes/DarkTheme.swift index 6d076e037..12f307882 100644 --- a/Riot/Managers/Theme/Themes/DarkTheme.swift +++ b/Riot/Managers/Theme/Themes/DarkTheme.swift @@ -101,7 +101,7 @@ class DarkTheme: NSObject, Theme { var roomCellLocalisationTextColor: UIColor = UIColor(rgb: 0x17191C) - var roomCellLocalisationStartedColor: UIColor = UIColor(rgb: 0x5C56F5) + var roomCellLocalisationIconStartedColor: UIColor = UIColor(rgb: 0x5C56F5) var roomCellLocalisationEndedColor: UIColor = UIColor(rgb: 0xC1C6CD) diff --git a/Riot/Managers/Theme/Themes/DefaultTheme.swift b/Riot/Managers/Theme/Themes/DefaultTheme.swift index 5eaee1185..f0951216a 100644 --- a/Riot/Managers/Theme/Themes/DefaultTheme.swift +++ b/Riot/Managers/Theme/Themes/DefaultTheme.swift @@ -107,7 +107,7 @@ class DefaultTheme: NSObject, Theme { var roomCellLocalisationTextColor: UIColor = UIColor(rgb: 0x17191C) - var roomCellLocalisationStartedColor: UIColor = UIColor(rgb: 0x5C56F5) + var roomCellLocalisationIconStartedColor: UIColor = UIColor(rgb: 0x5C56F5) var roomCellLocalisationEndedColor: UIColor = UIColor(rgb: 0xC1C6CD) diff --git a/Riot/Modules/Analytics/Analytics.swift b/Riot/Modules/Analytics/Analytics.swift index 74880e69f..39a4ffb54 100644 --- a/Riot/Modules/Analytics/Analytics.swift +++ b/Riot/Modules/Analytics/Analytics.swift @@ -261,12 +261,10 @@ extension Analytics { /// Track an E2EE error that occurred /// - Parameters: /// - reason: The error that occurred. - /// - count: The number of times that error occurred. - func trackE2EEError(_ reason: DecryptionFailureReason, count: Int) { - for _ in 0.. String? { + let timerInSec = timestamp / 1000 // Timestamp is in millisecond in the SDK let timerString: String? if isIncomingLocation { - timerString = VectorL10n.locationSharingLiveTimerIncoming(incomingTimerFormatter.string(from: Date(timeIntervalSince1970: timestamp))) - } else if let outgoingTimer = outgoingTimerFormatter.string(from: Date(timeIntervalSince1970: timestamp).timeIntervalSinceNow) { - timerString = VectorL10n.locationSharingLiveTimerOutgoing(outgoingTimer) + timerString = VectorL10n.locationSharingLiveTimerIncoming(incomingTimerFormatter.string(from: Date(timeIntervalSince1970: timerInSec))) + } else if let outgoingTimer = outgoingTimerFormatter.string(from: Date(timeIntervalSince1970: timerInSec).timeIntervalSinceNow) { + timerString = VectorL10n.locationSharingLiveListItemTimeLeft(outgoingTimer) } else { timerString = nil } @@ -313,9 +320,9 @@ class RoomTimelineLocationView: UIView, NibLoadable, Themable, MGLMapViewDelegat @IBAction private func didTapTightButton(_ sender: Any) { if rightButton.tag == RightButtonTag.stopSharing.rawValue { - delegate?.didTapStopButton() + delegate?.roomTimelineLocationViewDidTapStopButton(self) } else if rightButton.tag == RightButtonTag.retrySharing.rawValue { - delegate?.didTapRetryButton() + delegate?.roomTimelineLocationViewDidTapRetryButton(self) } } } diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Location/LocationPlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Location/LocationPlainCell.swift index a8f752f59..77f5f784d 100644 --- a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Location/LocationPlainCell.swift +++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Location/LocationPlainCell.swift @@ -87,10 +87,10 @@ class LocationPlainCell: SizableBaseRoomCell, RoomCellReactionsDisplayable, Room avatarUrl: bubbleData.senderAvatarUrl, mediaManager: bubbleData.mxSession.mediaManager, fallbackImage: .matrixItem(bubbleData.senderId, bubbleData.senderDisplayName)) - let futurDateTimeInterval = Date(timeIntervalSinceNow: 3734).timeIntervalSince1970 + let futurDateTimeInterval = Date(timeIntervalSinceNow: 3734).timeIntervalSince1970 * 1000 locationView.displayLiveLocation(with: RoomTimelineLocationViewData(location: location, userAvatarData: avatarViewData, mapStyleURL: mapStyleURL), - liveLocationViewState: .outgoing(.failure)) + liveLocationViewState: .outgoing(.started(futurDateTimeInterval))) } override func setupViews() { @@ -112,7 +112,7 @@ class LocationPlainCell: SizableBaseRoomCell, RoomCellReactionsDisplayable, Room } extension LocationPlainCell: RoomTimelineLocationViewDelegate { - func didTapStopButton() { + func roomTimelineLocationViewDidTapStopButton(_ roomTimelineLocationView: RoomTimelineLocationView) { guard let event = self.event else { return } @@ -120,7 +120,7 @@ extension LocationPlainCell: RoomTimelineLocationViewDelegate { delegate.cell(self, didRecognizeAction: kMXKRoomBubbleCellStopShareButtonPressed, userInfo: [kMXKRoomBubbleCellEventKey: event]) } - func didTapRetryButton() { + func roomTimelineLocationViewDidTapRetryButton(_ roomTimelineLocationView: RoomTimelineLocationView) { guard let event = self.event else { return } diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index cae73c224..3afc87705 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -20,17 +20,22 @@ import Foundation @available(iOS 14.0, *) enum MockAppScreens { static let appScreens: [MockScreenState.Type] = [ + MockLiveLocationSharingViewerScreenState.self, MockOnboardingCelebrationScreenState.self, MockOnboardingAvatarScreenState.self, MockOnboardingDisplayNameScreenState.self, MockOnboardingCongratulationsScreenState.self, MockOnboardingUseCaseSelectionScreenState.self, MockOnboardingSplashScreenScreenState.self, + MockStaticLocationViewingScreenState.self, MockLocationSharingScreenState.self, MockAnalyticsPromptScreenState.self, MockUserSuggestionScreenState.self, MockPollEditFormScreenState.self, MockSpaceCreationEmailInvitesScreenState.self, + MockSpaceSettingsScreenState.self, + MockRoomAccessTypeChooserScreenState.self, + MockRoomUpgradeScreenState.self, MockMatrixItemChooserScreenState.self, MockSpaceCreationMenuScreenState.self, MockSpaceCreationRoomsScreenState.self, diff --git a/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift b/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift index 5e29d179b..faf39e0e4 100644 --- a/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift +++ b/RiotSwiftUI/Modules/Common/Mock/ScreenList.swift @@ -30,12 +30,19 @@ struct ScreenList: View { var body: some View { NavigationView { List { - ForEach(0.. 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 + } +} diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerModels.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerModels.swift new file mode 100644 index 000000000..018aeee75 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerModels.swift @@ -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() + + var bindings = LocationSharingViewStateBindings() +} + +struct LiveLocationSharingViewerViewStateBindings { + var alertInfo: AlertInfo? +} + +enum LiveLocationSharingViewerViewAction { + case done + case stopSharing + case tapListItem(_ userId: String) + case share(_ annotation: UserLocationAnnotation) +} diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerViewModel.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerViewModel.swift new file mode 100644 index 000000000..9d8a31dc9 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerViewModel.swift @@ -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 +@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 + } +} diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerViewModelProtocol.swift new file mode 100644 index 000000000..64f489745 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerViewModelProtocol.swift @@ -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 } +} diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/MockLiveLocationSharingViewerScreenState.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/MockLiveLocationSharingViewerScreenState.swift new file mode 100644 index 000000000..10e198471 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/MockLiveLocationSharingViewerScreenState.swift @@ -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)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/LiveLocationSharingViewerServiceProtocol.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/LiveLocationSharingViewerServiceProtocol.swift new file mode 100644 index 000000000..8d272fb0a --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/LiveLocationSharingViewerServiceProtocol.swift @@ -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 +} diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/MatrixSDK/LiveLocationSharingViewerService.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/MatrixSDK/LiveLocationSharingViewerService.swift new file mode 100644 index 000000000..65a7da476 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/MatrixSDK/LiveLocationSharingViewerService.swift @@ -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 + } +} diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/Mock/MockLiveLocationSharingViewerService.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/Mock/MockLiveLocationSharingViewerService.swift new file mode 100644 index 000000000..c95e5c571 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/Mock/MockLiveLocationSharingViewerService.swift @@ -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) + } +} diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/UserLiveLocation.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/UserLiveLocation.swift new file mode 100644 index 000000000..015d09088 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/UserLiveLocation.swift @@ -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 +} diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Test/UI/LiveLocationSharingViewerUITests.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Test/UI/LiveLocationSharingViewerUITests.swift new file mode 100644 index 000000000..3137d1381 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Test/UI/LiveLocationSharingViewerUITests.swift @@ -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 + } +} diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Test/Unit/LiveLocationSharingViewerViewModelTests.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Test/Unit/LiveLocationSharingViewerViewModelTests.swift new file mode 100644 index 000000000..ec6f6a59b --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Test/Unit/LiveLocationSharingViewerViewModelTests.swift @@ -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() + + override func setUpWithError() throws { + service = MockLiveLocationSharingViewerService() + viewModel = LiveLocationSharingViewerViewModel(mapStyleURL: BuildSettings.tileServerMapStyleURL, service: service) + context = viewModel.context + } +} diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationListItem.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationListItem.swift new file mode 100644 index 000000000..c5f4b94d6 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationListItem.swift @@ -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) + } + } +} diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationListItemViewData.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationListItemViewData.swift new file mode 100644 index 000000000..3e2392225 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationListItemViewData.swift @@ -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 +} diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationSharingViewer.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationSharingViewer.swift new file mode 100644 index 000000000..790fbc580 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationSharingViewer.swift @@ -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) + } + } +} diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/LocationAnnotation.swift b/RiotSwiftUI/Modules/Room/LocationSharing/LocationAnnotation.swift index 545348f0e..3ddded983 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/LocationAnnotation.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/LocationAnnotation.swift @@ -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 } } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/MapViewErrorAlertInfoBuilder.swift b/RiotSwiftUI/Modules/Room/LocationSharing/MapViewErrorAlertInfoBuilder.swift index 63a1e8741..9f1120d2a 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/MapViewErrorAlertInfoBuilder.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/MapViewErrorAlertInfoBuilder.swift @@ -18,7 +18,7 @@ import Foundation struct MapViewErrorAlertInfoBuilder { - func build(with error: LocationSharingViewError, dimissalCallback: (() -> Void)?) -> AlertInfo? { + func build(with error: LocationSharingViewError, primaryButtonCompletion: (() -> Void)?) -> AlertInfo? { let alertInfo: AlertInfo? @@ -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 { } } - diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift index 9e947d111..97836ef64 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMapView.swift @@ -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 @@ -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) + } } } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMarkerView.swift b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMarkerView.swift index 3910b053f..3c36e7d50 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMarkerView.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingMarkerView.swift @@ -40,7 +40,6 @@ struct LocationSharingMarkerView: View { markerImage .frame(width: 40, height: 40) } - .offset(x: 0, y: -23) } } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/View/UserAnnotationCalloutContentView.swift b/RiotSwiftUI/Modules/Room/LocationSharing/View/UserAnnotationCalloutContentView.swift new file mode 100644 index 000000000..419213972 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LocationSharing/View/UserAnnotationCalloutContentView.swift @@ -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 + } +} diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/View/UserAnnotationCalloutContentView.xib b/RiotSwiftUI/Modules/Room/LocationSharing/View/UserAnnotationCalloutContentView.xib new file mode 100644 index 000000000..45c9be07f --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LocationSharing/View/UserAnnotationCalloutContentView.xib @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/View/UserAnnotationCalloutView.swift b/RiotSwiftUI/Modules/Room/LocationSharing/View/UserAnnotationCalloutView.swift new file mode 100644 index 000000000..64edcfd30 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/LocationSharing/View/UserAnnotationCalloutView.swift @@ -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) + } + } +} diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/View/UserLocationAnnotationView.swift b/RiotSwiftUI/Modules/Room/LocationSharing/View/UserLocationAnnotationView.swift index 414f60fc6..5b9956111 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/View/UserLocationAnnotationView.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/View/UserLocationAnnotationView.swift @@ -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 } } diff --git a/RiotSwiftUI/Modules/Room/RoomAccess/RoomAccessTypeChooser/Test/UI/RoomAccessTypeChooserUITests.swift b/RiotSwiftUI/Modules/Room/RoomAccess/RoomAccessTypeChooser/Test/UI/RoomAccessTypeChooserUITests.swift index 184d08dfd..628d50ccb 100644 --- a/RiotSwiftUI/Modules/Room/RoomAccess/RoomAccessTypeChooser/Test/UI/RoomAccessTypeChooserUITests.swift +++ b/RiotSwiftUI/Modules/Room/RoomAccess/RoomAccessTypeChooser/Test/UI/RoomAccessTypeChooserUITests.swift @@ -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. } diff --git a/RiotSwiftUI/Modules/Room/RoomUpgrade/Test/UI/RoomUpgradeUITests.swift b/RiotSwiftUI/Modules/Room/RoomUpgrade/Test/UI/RoomUpgradeUITests.swift index c7463ab27..e0e61b67e 100644 --- a/RiotSwiftUI/Modules/Room/RoomUpgrade/Test/UI/RoomUpgradeUITests.swift +++ b/RiotSwiftUI/Modules/Room/RoomUpgrade/Test/UI/RoomUpgradeUITests.swift @@ -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. } diff --git a/RiotSwiftUI/Modules/Room/StaticLocationSharingViewer/StaticLocationViewingViewModel.swift b/RiotSwiftUI/Modules/Room/StaticLocationSharingViewer/StaticLocationViewingViewModel.swift index 17af5c4cb..508aa6631 100644 --- a/RiotSwiftUI/Modules/Room/StaticLocationSharingViewer/StaticLocationViewingViewModel.swift +++ b/RiotSwiftUI/Modules/Room/StaticLocationSharingViewer/StaticLocationViewingViewModel.swift @@ -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 diff --git a/RiotSwiftUI/Modules/Room/StaticLocationSharingViewer/Test/UI/StaticLocationViewingUITests.swift b/RiotSwiftUI/Modules/Room/StaticLocationSharingViewer/Test/UI/StaticLocationViewingUITests.swift index 8e73be761..01e2635cb 100644 --- a/RiotSwiftUI/Modules/Room/StaticLocationSharingViewer/Test/UI/StaticLocationViewingUITests.swift +++ b/RiotSwiftUI/Modules/Room/StaticLocationSharingViewer/Test/UI/StaticLocationViewingUITests.swift @@ -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.") } } diff --git a/RiotSwiftUI/Modules/Room/StaticLocationSharingViewer/View/StaticLocationView.swift b/RiotSwiftUI/Modules/Room/StaticLocationSharingViewer/View/StaticLocationView.swift index 1ba73aac0..a217d3afc 100644 --- a/RiotSwiftUI/Modules/Room/StaticLocationSharingViewer/View/StaticLocationView.swift +++ b/RiotSwiftUI/Modules/Room/StaticLocationSharingViewer/View/StaticLocationView.swift @@ -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) diff --git a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MockMatrixItemChooserScreenState.swift b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MockMatrixItemChooserScreenState.swift index a8f1d729b..29f07f534 100644 --- a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MockMatrixItemChooserScreenState.swift +++ b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MockMatrixItemChooserScreenState.swift @@ -44,7 +44,9 @@ enum MockMatrixItemChooserScreenState: MockScreenState, CaseIterable { case .selectedItems: service = MockMatrixItemChooserService(type: .room, sections: MockMatrixItemChooserService.mockSections, selectedItemIndexPaths: [IndexPath(row: 0, section: 0), IndexPath(row: 2, section: 0), IndexPath(row: 1, section: 1)]) } - let viewModel = MatrixItemChooserViewModel.makeMatrixItemChooserViewModel(matrixItemChooserService: service, title: "Some title", detail: "Detail text describing the current screen") + let viewModel = MatrixItemChooserViewModel.makeMatrixItemChooserViewModel(matrixItemChooserService: service, + title: VectorL10n.spacesCreationAddRoomsTitle, + detail: VectorL10n.spacesCreationAddRoomsMessage) // can simulate service and viewModel actions here if needs be. diff --git a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Test/UI/MatrixItemChooserUITests.swift b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Test/UI/MatrixItemChooserUITests.swift index e292151b9..c5b9c9a96 100644 --- a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Test/UI/MatrixItemChooserUITests.swift +++ b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Test/UI/MatrixItemChooserUITests.swift @@ -45,21 +45,18 @@ class MatrixItemChooserUITests: MockScreenTest { XCTAssertEqual(app.staticTexts["messageText"].label, VectorL10n.spacesCreationAddRoomsMessage) XCTAssertEqual(app.staticTexts["emptyListMessage"].exists, true) XCTAssertEqual(app.staticTexts["emptyListMessage"].label, VectorL10n.spacesNoResultFoundTitle) - XCTAssertEqual(app.buttons["doneButton"].label, VectorL10n.skip) } func verifyPopulatedScreen() { XCTAssertEqual(app.staticTexts["titleText"].label, VectorL10n.spacesCreationAddRoomsTitle) XCTAssertEqual(app.staticTexts["messageText"].label, VectorL10n.spacesCreationAddRoomsMessage) XCTAssertEqual(app.staticTexts["emptyListMessage"].exists, false) - XCTAssertEqual(app.buttons["doneButton"].label, VectorL10n.skip) } func verifyPopulatedWithSelectionScreen() { XCTAssertEqual(app.staticTexts["titleText"].label, VectorL10n.spacesCreationAddRoomsTitle) XCTAssertEqual(app.staticTexts["messageText"].label, VectorL10n.spacesCreationAddRoomsMessage) XCTAssertEqual(app.staticTexts["emptyListMessage"].exists, false) - XCTAssertEqual(app.buttons["doneButton"].label, VectorL10n.next) } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Test/UI/SpaceSettingsUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Test/UI/SpaceSettingsUITests.swift index 2e47c2f76..1449bd93d 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Test/UI/SpaceSettingsUITests.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Test/UI/SpaceSettingsUITests.swift @@ -19,17 +19,5 @@ import RiotSwiftUI @available(iOS 14.0, *) class SpaceSettingsUITests: MockScreenTest { - - override class var screenType: MockScreenState.Type { - return MockSpaceSettingsScreenState.self - } - - override class func createTest() -> MockScreenTest { - return SpaceSettingsUITests(selector: #selector(verifySpaceSettingsScreen)) - } - - func verifySpaceSettingsScreen() throws { - guard let screenState = screenState as? MockSpaceSettingsScreenState else { fatalError("no screen") } - } - + // Tests to be implemented. } diff --git a/RiotSwiftUI/target.yml b/RiotSwiftUI/target.yml index 58f3e74f3..f2feb1940 100644 --- a/RiotSwiftUI/target.yml +++ b/RiotSwiftUI/target.yml @@ -52,6 +52,7 @@ targets: - path: ../Riot/Categories/Character.swift - path: ../Riot/Categories/UIColor.swift - path: ../Riot/Categories/UISearchBar.swift + - path: ../Riot/Categories/UIView.swift - path: ../Riot/Assets/en.lproj/Vector.strings - path: ../Riot/Modules/Analytics/AnalyticsScreen.swift - path: ../Riot/Assets/en.lproj/Untranslated.strings diff --git a/RiotSwiftUI/targetUITests.yml b/RiotSwiftUI/targetUITests.yml index 324fcb0d0..b7a0f9d00 100644 --- a/RiotSwiftUI/targetUITests.yml +++ b/RiotSwiftUI/targetUITests.yml @@ -39,6 +39,7 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: org.matrix.RiotSwiftUITests$(rfc1034identifier) SWIFT_OBJC_BRIDGING_HEADER: $(SRCROOT)/RiotSwiftUI/RiotSwiftUI-Bridging-Header.h SWIFT_OBJC_INTERFACE_HEADER_NAME: GeneratedInterface-Swift.h + GENERATE_INFOPLIST_FILE: YES sources: # Source included/excluded here here are similar to RiotSwiftUI as we # need access to ScreenStates @@ -60,6 +61,7 @@ targets: - path: ../Riot/Categories/Character.swift - path: ../Riot/Categories/UIColor.swift - path: ../Riot/Categories/UISearchBar.swift + - path: ../Riot/Categories/UIView.swift - path: ../Riot/Assets/en.lproj/Vector.strings - path: ../Riot/Modules/Analytics/AnalyticsScreen.swift - path: ../Riot/Assets/en.lproj/Untranslated.strings diff --git a/Tools/SwiftGen/Templates/Strings/flat-swift4-vector.stencil b/Tools/SwiftGen/Templates/Strings/flat-swift4-vector.stencil index 3e3e6f594..133587548 100644 --- a/Tools/SwiftGen/Templates/Strings/flat-swift4-vector.stencil +++ b/Tools/SwiftGen/Templates/Strings/flat-swift4-vector.stencil @@ -64,17 +64,20 @@ import Foundation extension {{className}} { static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { - let format = NSLocalizedString(key, tableName: table, bundle: Bundle.app, comment: "") - let locale: Locale - - if let providedLocale = LocaleProvider.locale { - locale = providedLocale - } else { - locale = Locale.current - } - - return String(format: format, locale: locale, arguments: args) + let format = NSLocalizedString(key, tableName: table, bundle: bundle, comment: "") + let locale = LocaleProvider.locale ?? Locale.current + + return String(format: format, locale: locale, arguments: args) + } + /// The bundle to load strings from. This will be the app's bundle unless running + /// the UI tests target, in which case the strings are contained in the tests bundle. + static let bundle: Bundle = { + if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { + // The tests bundle is embedded inside a runner. Find the bundle for VectorL10n. + return Bundle(for: VectorL10n.self) } + return Bundle.app + }() } {% else %} diff --git a/changelog.d/5723.wip b/changelog.d/5723.wip new file mode 100644 index 000000000..f5a7245dc --- /dev/null +++ b/changelog.d/5723.wip @@ -0,0 +1 @@ +Location sharing: Add live location viewer screen. diff --git a/changelog.d/6046_uisi_context b/changelog.d/6046_uisi_context new file mode 100644 index 000000000..317480d92 --- /dev/null +++ b/changelog.d/6046_uisi_context @@ -0,0 +1 @@ +Analytics: Log decryption error details as context in AnalyticsEvent diff --git a/changelog.d/6050.build b/changelog.d/6050.build new file mode 100644 index 000000000..68d4e07c8 --- /dev/null +++ b/changelog.d/6050.build @@ -0,0 +1 @@ +UI Tests: Fix broken tests and add a check on PRs.