409 lines
16 KiB
Swift
409 lines
16 KiB
Swift
//
|
|
// Copyright 2021-2024 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Please see LICENSE in the repository root for full details.
|
|
//
|
|
|
|
import UIKit
|
|
import Reusable
|
|
import Mapbox
|
|
import SwiftUI
|
|
|
|
protocol RoomTimelineLocationViewDelegate: AnyObject {
|
|
func roomTimelineLocationViewDidTapStopButton(_ roomTimelineLocationView: RoomTimelineLocationView)
|
|
func roomTimelineLocationViewDidTapRetryButton(_ roomTimelineLocationView: RoomTimelineLocationView)
|
|
}
|
|
|
|
struct RoomTimelineLocationViewData {
|
|
let location: CLLocationCoordinate2D?
|
|
let userAvatarData: AvatarViewData?
|
|
let mapStyleURL: URL
|
|
}
|
|
|
|
struct TimelineLiveLocationViewData {
|
|
let status: LiveLocationSharingStatus
|
|
let iconTint: UIColor
|
|
let title: String
|
|
let titleColor: UIColor
|
|
let timeLeftString: String?
|
|
let rightButtonTitle: String?
|
|
let rightButtonTag: RightButtonTag
|
|
let coordinate: CLLocationCoordinate2D?
|
|
|
|
var showTimer: Bool {
|
|
return timeLeftString != nil
|
|
}
|
|
|
|
var showRightButton: Bool {
|
|
return rightButtonTitle != nil
|
|
}
|
|
|
|
var showMap: Bool {
|
|
guard case .started = status else {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
enum TimelineLiveLocationViewState {
|
|
case incoming(_ status: LiveLocationSharingStatus) // live location started by other users
|
|
case outgoing(_ status: LiveLocationSharingStatus) // live location started from current user
|
|
}
|
|
|
|
|
|
enum LiveLocationSharingStatus {
|
|
case starting
|
|
case started(_ coordinate: CLLocationCoordinate2D, _ timeleft: TimeInterval)
|
|
case failure
|
|
case stopped
|
|
}
|
|
|
|
enum RightButtonTag: Int {
|
|
case stopSharing = 0
|
|
case retrySharing
|
|
}
|
|
|
|
class RoomTimelineLocationView: UIView, NibLoadable, Themable, MGLMapViewDelegate {
|
|
|
|
// MARK: - Constants
|
|
|
|
private struct Constants {
|
|
static let mapHeight: CGFloat = 300.0
|
|
static let mapZoomLevel = 15.0
|
|
static let cellBorderRadius: CGFloat = 1.0
|
|
static let cellCornerRadius: CGFloat = 8.0
|
|
}
|
|
|
|
// MARK: - Properties
|
|
// MARK: Private
|
|
|
|
@IBOutlet private var descriptionContainerView: UIView!
|
|
@IBOutlet private var descriptionLabel: UILabel!
|
|
@IBOutlet private var descriptionIcon: UIImageView!
|
|
@IBOutlet private var attributionLabel: UILabel!
|
|
|
|
// MARK: - Live Location
|
|
@IBOutlet private var placeholderBackground: UIImageView!
|
|
@IBOutlet private var placeholderIconView: UIImageView!
|
|
@IBOutlet private var liveLocationContainerView: UIView!
|
|
@IBOutlet private var liveLocationIcon: UIImageView!
|
|
@IBOutlet private var liveLocationIconBackgroundView: UIView!
|
|
@IBOutlet private var liveLocationStatusLabel: UILabel!
|
|
@IBOutlet private var liveLocationTimerLabel: UILabel!
|
|
@IBOutlet private var rightButton: UIButton!
|
|
@IBOutlet private var activityIndicatorView: UIActivityIndicatorView!
|
|
|
|
@IBOutlet private var mapLoadingErrorContainerView: UIView!
|
|
@IBOutlet private var mapLoadingErrorImageView: UIImageView!
|
|
@IBOutlet private var mapLoadingErrorMessageLabel: UILabel!
|
|
|
|
private var mapView: MGLMapView!
|
|
|
|
private var isMapViewLoadingFailed: Bool = false {
|
|
didSet {
|
|
if oldValue != isMapViewLoadingFailed {
|
|
self.mapViewLoadingStateDidChange()
|
|
}
|
|
}
|
|
}
|
|
private var annotationView: LocationMarkerView?
|
|
private static var usernameColorGenerator = UserNameColorGenerator()
|
|
private var theme: Theme!
|
|
private var placeholderBackgroundImage: UIImage?
|
|
private var placeholderIcon: UIImage?
|
|
|
|
private lazy var incomingTimerFormatter: DateFormatter = {
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateFormat = "HH:mm"
|
|
return dateFormatter
|
|
}()
|
|
|
|
private lazy var outgoingTimerFormatter: DateComponentsFormatter = {
|
|
let formatter = DateComponentsFormatter()
|
|
formatter.zeroFormattingBehavior = .dropAll
|
|
formatter.allowedUnits = [.hour, .minute, .second]
|
|
formatter.unitsStyle = .brief
|
|
return formatter
|
|
}()
|
|
|
|
weak var delegate: RoomTimelineLocationViewDelegate?
|
|
|
|
// MARK: Public
|
|
|
|
var locationDescription: String? {
|
|
get {
|
|
descriptionLabel.text
|
|
}
|
|
set {
|
|
descriptionLabel.text = newValue
|
|
descriptionContainerView.isHidden = (newValue?.count ?? 0 == 0)
|
|
}
|
|
}
|
|
|
|
override func awakeFromNib() {
|
|
super.awakeFromNib()
|
|
|
|
mapView = MGLMapView(frame: .zero)
|
|
mapView.delegate = self
|
|
mapView.logoView.isHidden = true
|
|
mapView.attributionButton.isHidden = true
|
|
mapView.isUserInteractionEnabled = false
|
|
|
|
mapView.translatesAutoresizingMaskIntoConstraints = false
|
|
mapView.addConstraint(mapView.heightAnchor.constraint(equalToConstant: Constants.mapHeight))
|
|
vc_addSubViewMatchingParent(mapView)
|
|
sendSubviewToBack(mapView)
|
|
|
|
clipsToBounds = true
|
|
layer.borderWidth = Constants.cellBorderRadius
|
|
layer.cornerRadius = Constants.cellCornerRadius
|
|
|
|
mapLoadingErrorContainerView.isHidden = true
|
|
mapLoadingErrorImageView.image = Asset.Images.locationMapError.image
|
|
mapLoadingErrorMessageLabel.text = VectorL10n.locationSharingMapLoadingError
|
|
|
|
theme = ThemeService.shared().theme
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func resetMapViewLoadingState() {
|
|
self.isMapViewLoadingFailed = false
|
|
}
|
|
|
|
private func mapViewLoadingStateDidChange() {
|
|
|
|
if mapView.isHidden == false && self.isMapViewLoadingFailed {
|
|
mapLoadingErrorContainerView.isHidden = false
|
|
mapView.isHidden = true
|
|
attributionLabel.isHidden = true
|
|
} else {
|
|
mapLoadingErrorContainerView.isHidden = true
|
|
}
|
|
}
|
|
|
|
private func displayLocation(_ location: CLLocationCoordinate2D?,
|
|
userAvatarData: AvatarViewData? = nil,
|
|
mapStyleURL: URL,
|
|
bannerViewData: TimelineLiveLocationViewData? = nil) {
|
|
|
|
resetMapViewLoadingState()
|
|
|
|
if let location = location {
|
|
mapView.isHidden = false
|
|
mapView.styleURL = mapStyleURL
|
|
|
|
annotationView = LocationMarkerView.loadFromNib()
|
|
|
|
if let userAvatarData = userAvatarData {
|
|
let avatarBackgroundColor = Self.usernameColorGenerator.color(from: userAvatarData.matrixItemId)
|
|
annotationView?.setAvatarData(userAvatarData, avatarBackgroundColor: avatarBackgroundColor)
|
|
}
|
|
|
|
if let annotations = mapView.annotations {
|
|
mapView.removeAnnotations(annotations)
|
|
}
|
|
|
|
mapView.setCenter(location, zoomLevel: Constants.mapZoomLevel, animated: false)
|
|
|
|
let pointAnnotation = MGLPointAnnotation()
|
|
pointAnnotation.coordinate = location
|
|
mapView.addAnnotation(pointAnnotation)
|
|
} else {
|
|
mapView.isHidden = true
|
|
}
|
|
|
|
// Configure live location banner
|
|
guard let bannerViewData = bannerViewData else {
|
|
liveLocationContainerView.isHidden = true
|
|
placeholderBackground.isHidden = true
|
|
placeholderIconView.isHidden = true
|
|
return
|
|
}
|
|
|
|
liveLocationContainerView.isHidden = false
|
|
liveLocationContainerView.backgroundColor = theme.colors.background.withAlphaComponent(0.90)
|
|
|
|
liveLocationIcon.image = Asset.Images.locationLiveCellIcon.image
|
|
liveLocationIcon.tintColor = bannerViewData.iconTint
|
|
liveLocationIconBackgroundView.isHidden = !bannerViewData.showMap // Add white background when cell is not in starting or ended state
|
|
|
|
liveLocationStatusLabel.text = bannerViewData.title
|
|
liveLocationStatusLabel.textColor = bannerViewData.titleColor
|
|
|
|
liveLocationTimerLabel.text = bannerViewData.timeLeftString
|
|
liveLocationTimerLabel.textColor = theme.colors.tertiaryContent
|
|
liveLocationTimerLabel.isHidden = !bannerViewData.showTimer
|
|
|
|
rightButton.setTitle(bannerViewData.rightButtonTitle, for: .normal)
|
|
rightButton.isHidden = !bannerViewData.showRightButton
|
|
rightButton.tag = bannerViewData.rightButtonTag.rawValue
|
|
|
|
placeholderBackground.isHidden = bannerViewData.showMap
|
|
placeholderIconView.image = placeholderIcon
|
|
placeholderIconView.isHidden = bannerViewData.showMap
|
|
placeholderBackground.isHidden = bannerViewData.showMap
|
|
placeholderBackground.image = placeholderBackgroundImage
|
|
mapView.isHidden = !bannerViewData.showMap
|
|
attributionLabel.isHidden = !bannerViewData.showMap
|
|
|
|
switch bannerViewData.status {
|
|
case .starting:
|
|
placeholderIconView.isHidden = true
|
|
activityIndicatorView.isHidden = false
|
|
activityIndicatorView.startAnimating()
|
|
default:
|
|
activityIndicatorView.isHidden = true
|
|
activityIndicatorView.stopAnimating()
|
|
}
|
|
}
|
|
|
|
private func liveLocationBannerViewData(from viewState: TimelineLiveLocationViewState) -> TimelineLiveLocationViewData {
|
|
|
|
let status: LiveLocationSharingStatus
|
|
let iconTint: UIColor
|
|
let title: String
|
|
var titleColor: UIColor = theme.colors.primaryContent
|
|
var timeLeftString: String?
|
|
var rightButtonTitle: String?
|
|
var rightButtonTag: RightButtonTag = .stopSharing
|
|
var liveCoordinate: CLLocationCoordinate2D?
|
|
|
|
switch viewState {
|
|
case .incoming(let liveLocationSharingStatus):
|
|
status = liveLocationSharingStatus
|
|
switch liveLocationSharingStatus {
|
|
case .starting:
|
|
iconTint = theme.colors.quarterlyContent
|
|
title = VectorL10n.locationSharingLiveLoading
|
|
titleColor = theme.colors.tertiaryContent
|
|
case .started(let coordinate, let timeLeft):
|
|
iconTint = theme.roomCellLocalisationIconStartedColor
|
|
title = VectorL10n.liveLocationSharingBannerTitle
|
|
timeLeftString = generateTimerString(for: timeLeft, isIncomingLocation: true)
|
|
liveCoordinate = coordinate
|
|
case .failure:
|
|
iconTint = theme.roomCellLocalisationErrorColor
|
|
title = VectorL10n.locationSharingLiveError
|
|
rightButtonTitle = VectorL10n.retry
|
|
rightButtonTag = .retrySharing
|
|
case .stopped:
|
|
iconTint = theme.colors.quarterlyContent
|
|
title = VectorL10n.liveLocationSharingEnded
|
|
titleColor = theme.colors.tertiaryContent
|
|
}
|
|
case .outgoing(let liveLocationSharingStatus):
|
|
status = liveLocationSharingStatus
|
|
switch liveLocationSharingStatus {
|
|
case .starting:
|
|
iconTint = theme.colors.quarterlyContent
|
|
title = VectorL10n.locationSharingLiveLoading
|
|
titleColor = theme.colors.tertiaryContent
|
|
case .started(let coordinate, let timeLeft):
|
|
iconTint = theme.roomCellLocalisationIconStartedColor
|
|
title = VectorL10n.liveLocationSharingBannerTitle
|
|
timeLeftString = generateTimerString(for: timeLeft, isIncomingLocation: false)
|
|
rightButtonTitle = VectorL10n.stop
|
|
liveCoordinate = coordinate
|
|
case .failure:
|
|
iconTint = theme.roomCellLocalisationErrorColor
|
|
title = VectorL10n.locationSharingLiveError
|
|
rightButtonTitle = VectorL10n.retry
|
|
rightButtonTag = .retrySharing
|
|
case .stopped:
|
|
iconTint = theme.colors.quarterlyContent
|
|
title = VectorL10n.liveLocationSharingEnded
|
|
titleColor = theme.colors.tertiaryContent
|
|
}
|
|
}
|
|
|
|
return TimelineLiveLocationViewData(status: status,
|
|
iconTint: iconTint,
|
|
title: title,
|
|
titleColor: titleColor,
|
|
timeLeftString: timeLeftString,
|
|
rightButtonTitle: rightButtonTitle,
|
|
rightButtonTag: rightButtonTag,
|
|
coordinate: liveCoordinate)
|
|
}
|
|
|
|
private func generateTimerString(for timestamp: Double,
|
|
isIncomingLocation: Bool) -> String? {
|
|
let timerInSec = timestamp
|
|
let timerString: String?
|
|
if isIncomingLocation {
|
|
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
|
|
}
|
|
return timerString
|
|
}
|
|
|
|
// MARK: - Public
|
|
|
|
public func displayStaticLocation(with viewData: RoomTimelineLocationViewData) {
|
|
displayLocation(viewData.location,
|
|
userAvatarData: viewData.userAvatarData,
|
|
mapStyleURL: viewData.mapStyleURL,
|
|
bannerViewData: nil)
|
|
}
|
|
|
|
public func displayLiveLocation(with viewData: RoomTimelineLocationViewData, liveLocationViewState: TimelineLiveLocationViewState) {
|
|
let bannerViewData = liveLocationBannerViewData(from: liveLocationViewState)
|
|
displayLocation(bannerViewData.coordinate,
|
|
userAvatarData: viewData.userAvatarData,
|
|
mapStyleURL: viewData.mapStyleURL,
|
|
bannerViewData: bannerViewData)
|
|
|
|
}
|
|
|
|
|
|
// MARK: - Themable
|
|
|
|
func update(theme: Theme) {
|
|
Self.usernameColorGenerator.update(theme: theme)
|
|
descriptionLabel.textColor = theme.colors.primaryContent
|
|
descriptionLabel.font = theme.fonts.footnote
|
|
descriptionIcon.tintColor = theme.colors.accent
|
|
attributionLabel.textColor = theme.colors.accent
|
|
layer.borderColor = theme.colors.quinaryContent.cgColor
|
|
self.theme = theme
|
|
placeholderIcon = ThemeService.shared().isCurrentThemeDark() ? Asset.Images.locationLiveCellEndedDarkIcon.image : Asset.Images.locationLiveCellEndedLightIcon.image
|
|
placeholderBackgroundImage = ThemeService.shared().isCurrentThemeDark() ? Asset.Images.locationBackgroundDarkImage.image : Asset.Images.locationBackgroundLightImage.image
|
|
|
|
mapLoadingErrorContainerView.backgroundColor = theme.colors.system
|
|
mapLoadingErrorMessageLabel.textColor = theme.colors.primaryContent
|
|
}
|
|
|
|
// MARK: - MGLMapViewDelegate
|
|
|
|
func mapView(_ mapView: MGLMapView, viewFor annotation: MGLAnnotation) -> MGLAnnotationView? {
|
|
return annotationView
|
|
}
|
|
|
|
func mapViewDidFailLoadingMap(_ mapView: MGLMapView, withError error: Error) {
|
|
|
|
MXLog.error("[RoomTimelineLocationView] Failed to load map", context: error)
|
|
|
|
self.isMapViewLoadingFailed = true
|
|
}
|
|
|
|
func mapViewDidFinishLoadingMap(_ mapView: MGLMapView) {
|
|
self.isMapViewLoadingFailed = false
|
|
}
|
|
|
|
// MARK: - Action
|
|
|
|
@IBAction private func didTapTightButton(_ sender: Any) {
|
|
if rightButton.tag == RightButtonTag.stopSharing.rawValue {
|
|
delegate?.roomTimelineLocationViewDidTapStopButton(self)
|
|
} else if rightButton.tag == RightButtonTag.retrySharing.rawValue {
|
|
delegate?.roomTimelineLocationViewDidTapRetryButton(self)
|
|
}
|
|
}
|
|
}
|