673 lines
30 KiB
Swift
673 lines
30 KiB
Swift
//
|
||
// SOSView.swift
|
||
// QuickLocation
|
||
//
|
||
|
||
import UIKit
|
||
import SwiftyUserDefaults
|
||
|
||
enum SOSPageState {
|
||
case idle
|
||
case countdown
|
||
case sending
|
||
case active
|
||
}
|
||
|
||
final class SOSView: UIView {
|
||
|
||
var onCountdownCompleted: (() -> Void)?
|
||
var onCancelActive: (() -> Void)?
|
||
var onManageContacts: (() -> Void)?
|
||
|
||
private(set) var state: SOSPageState = .idle
|
||
let sosPracticeView = SOSPracticeView()
|
||
|
||
private let activeBackgroundView = SOSActiveBackgroundView()
|
||
private let navView = BaseNavigationView(title: "紧急求救")
|
||
private let infoCard = UIView()
|
||
private let idleView = UIView()
|
||
private let countdownView = UIView()
|
||
private let activeView = UIView()
|
||
private let idlePulseView = SOSPulseButtonView()
|
||
private let activePulseView = SOSPulseButtonView()
|
||
private let countdownRingView = SOSCountdownRingView()
|
||
private let countdownTitleLabel = UILabel()
|
||
private let countdownBodyLabel = UILabel()
|
||
private let countdownSlider = SOSSlideCancelView()
|
||
private let activeSlider = SOSSlideCancelView()
|
||
private let contactStack = UIStackView()
|
||
private let contactScrollView = UIScrollView()
|
||
private let contactPanel = UIView()
|
||
private let contactStateStack = UIStackView()
|
||
private lazy var addContactCard = makeAddContactCard()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
setupUI()
|
||
setupActions()
|
||
configureContacts([])
|
||
setState(.idle)
|
||
|
||
let hasCompletedPractice = Defaults[\.sosIsPracticeList].contains(AppContextManager.shared.userId)
|
||
sosPracticeView.isHidden = hasCompletedPractice
|
||
backgroundColor = hasCompletedPractice ? UIColor(hexStr: "#F9F9F9") : .white
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func setState(_ state: SOSPageState) {
|
||
self.state = state
|
||
idleView.isHidden = state != .idle
|
||
countdownView.isHidden = state != .countdown && state != .sending
|
||
activeView.isHidden = state != .active
|
||
activeBackgroundView.isHidden = state != .active
|
||
|
||
if state == .active {
|
||
activeBackgroundView.startFlashing()
|
||
} else {
|
||
activeBackgroundView.stopFlashing()
|
||
}
|
||
|
||
switch state {
|
||
case .idle:
|
||
countdownRingView.stop()
|
||
countdownSlider.configure(style: .countdown)
|
||
countdownSlider.setInteractionEnabled(true)
|
||
idlePulseView.setShowsHand(true)
|
||
idlePulseView.setAnimationEnabled(true)
|
||
idlePulseView.isUserInteractionEnabled = true
|
||
activePulseView.setAnimationEnabled(false)
|
||
case .countdown:
|
||
idlePulseView.setAnimationEnabled(false)
|
||
activePulseView.setAnimationEnabled(false)
|
||
countdownTitleLabel.attributedText = Self.cancelTitle
|
||
countdownBodyLabel.text = "10秒后,会将你的SOS和位置发送到你的圈子和紧急联系人。"
|
||
countdownSlider.configure(style: .countdown)
|
||
countdownSlider.setInteractionEnabled(true)
|
||
countdownRingView.start(duration: 10)
|
||
case .sending:
|
||
countdownRingView.stop(reset: false)
|
||
countdownTitleLabel.text = "正在发送"
|
||
countdownTitleLabel.textColor = UIColor(hexStr: "#273246")
|
||
countdownBodyLabel.text = "正在发送你的SOS和位置信息,请稍候。"
|
||
countdownSlider.setInteractionEnabled(false)
|
||
case .active:
|
||
countdownRingView.stop()
|
||
activeSlider.configure(style: .active)
|
||
activeSlider.setInteractionEnabled(true)
|
||
idlePulseView.setAnimationEnabled(false)
|
||
activePulseView.setShowsHand(false)
|
||
activePulseView.setAnimationEnabled(true)
|
||
activePulseView.isUserInteractionEnabled = false
|
||
}
|
||
}
|
||
|
||
func setActiveCancelEnabled(_ enabled: Bool) {
|
||
activeSlider.setInteractionEnabled(enabled)
|
||
if enabled {
|
||
activeSlider.reset(animated: false)
|
||
}
|
||
}
|
||
|
||
func configureContacts(_ contacts: [EmergencyContactModel]) {
|
||
contactStack.arrangedSubviews.forEach {
|
||
contactStack.removeArrangedSubview($0)
|
||
$0.removeFromSuperview()
|
||
}
|
||
|
||
contactPanel.isHidden = contacts.isEmpty
|
||
addContactCard.isHidden = !contacts.isEmpty
|
||
guard !contacts.isEmpty else { return }
|
||
|
||
contacts.forEach { contact in
|
||
let card = SOSEmergencyContactCard()
|
||
card.configure(contact)
|
||
card.widthAnchor.constraint(equalToConstant: 100).isActive = true
|
||
contactStack.addArrangedSubview(card)
|
||
}
|
||
}
|
||
|
||
func stopTransientActivities() {
|
||
guard state == .countdown else { return }
|
||
countdownRingView.stop()
|
||
setState(.idle)
|
||
}
|
||
|
||
private func setupUI() {
|
||
addSubview(activeBackgroundView)
|
||
activeBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([
|
||
activeBackgroundView.topAnchor.constraint(equalTo: topAnchor),
|
||
activeBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||
activeBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||
activeBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||
])
|
||
|
||
setupNavigation()
|
||
setupInfoCard()
|
||
|
||
addSubview(idleView)
|
||
addSubview(countdownView)
|
||
addSubview(activeView)
|
||
[idleView, countdownView, activeView].forEach {
|
||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([
|
||
$0.topAnchor.constraint(equalTo: infoCard.bottomAnchor, constant: 6),
|
||
$0.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||
$0.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||
$0.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||
])
|
||
}
|
||
|
||
setupIdleView()
|
||
setupCountdownView()
|
||
setupActiveView()
|
||
|
||
addSubview(sosPracticeView)
|
||
sosPracticeView.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([
|
||
sosPracticeView.topAnchor.constraint(equalTo: navView.bottomAnchor),
|
||
sosPracticeView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||
sosPracticeView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||
sosPracticeView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||
])
|
||
}
|
||
|
||
private func setupNavigation() {
|
||
addSubview(navView)
|
||
navView.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([
|
||
navView.topAnchor.constraint(equalTo: topAnchor),
|
||
navView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||
navView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||
navView.heightAnchor.constraint(equalToConstant: kNaviHeight)
|
||
])
|
||
|
||
navView.titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||
let navigationOffset = CGAffineTransform(translationX: 0, y: -7)
|
||
navView.titleLabel.transform = navigationOffset
|
||
navView.backBtn.transform = navigationOffset
|
||
let moreButton = UIButton(type: .custom)
|
||
moreButton.setImage(UIImage(named: "SOS/sos_nav_more"), for: .normal)
|
||
moreButton.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
||
moreButton.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
||
moreButton.transform = CGAffineTransform(a: 1.5, b: 0, c: 0, d: 1.5, tx: -7, ty: -7)
|
||
moreButton.accessibilityLabel = "更多"
|
||
navView.addRightButton(moreButton)
|
||
}
|
||
|
||
private func setupInfoCard() {
|
||
infoCard.backgroundColor = UIColor.white.withAlphaComponent(0.93)
|
||
infoCard.layer.cornerRadius = 14
|
||
infoCard.layer.shadowColor = UIColor.black.cgColor
|
||
infoCard.layer.shadowOpacity = 0.035
|
||
infoCard.layer.shadowRadius = 10
|
||
infoCard.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||
addSubview(infoCard)
|
||
infoCard.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
let iconBackground = UIView()
|
||
infoCard.addSubview(iconBackground)
|
||
iconBackground.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
let iconView = UIImageView(image: UIImage(named: "SOS/sos_info_alert"))
|
||
iconView.contentMode = .scaleAspectFit
|
||
iconBackground.addSubview(iconView)
|
||
iconView.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
let titleLabel = UILabel()
|
||
titleLabel.text = "遇到紧急情况?一键发送求救信息"
|
||
titleLabel.textColor = UIColor(hexStr: "#273246")
|
||
titleLabel.font = .systemFont(ofSize: 12, weight: .semibold)
|
||
infoCard.addSubview(titleLabel)
|
||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
let subtitleLabel = UILabel()
|
||
subtitleLabel.text = "我们将向您的紧急联系人发送包含您位置和求救信息的通知"
|
||
subtitleLabel.textColor = UIColor(hexStr: "#888888")
|
||
subtitleLabel.font = .systemFont(ofSize: 10, weight: .regular)
|
||
subtitleLabel.adjustsFontSizeToFitWidth = true
|
||
subtitleLabel.minimumScaleFactor = 0.8
|
||
infoCard.addSubview(subtitleLabel)
|
||
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
NSLayoutConstraint.activate([
|
||
infoCard.topAnchor.constraint(equalTo: navView.bottomAnchor, constant: 10),
|
||
infoCard.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 15),
|
||
infoCard.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -15),
|
||
infoCard.heightAnchor.constraint(equalToConstant: 48),
|
||
|
||
iconBackground.leadingAnchor.constraint(equalTo: infoCard.leadingAnchor, constant: 10),
|
||
iconBackground.centerYAnchor.constraint(equalTo: infoCard.centerYAnchor),
|
||
iconBackground.widthAnchor.constraint(equalToConstant: 30),
|
||
iconBackground.heightAnchor.constraint(equalToConstant: 30),
|
||
|
||
iconView.centerXAnchor.constraint(equalTo: iconBackground.centerXAnchor),
|
||
iconView.centerYAnchor.constraint(equalTo: iconBackground.centerYAnchor),
|
||
iconView.widthAnchor.constraint(equalToConstant: 30),
|
||
iconView.heightAnchor.constraint(equalToConstant: 30),
|
||
|
||
titleLabel.topAnchor.constraint(equalTo: infoCard.topAnchor, constant: 7),
|
||
titleLabel.leadingAnchor.constraint(equalTo: iconBackground.trailingAnchor, constant: 6),
|
||
titleLabel.trailingAnchor.constraint(equalTo: infoCard.trailingAnchor, constant: -8),
|
||
|
||
subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 5),
|
||
subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
|
||
subtitleLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor)
|
||
])
|
||
}
|
||
|
||
private func setupIdleView() {
|
||
let scrollView = makeStateScrollView(in: idleView)
|
||
let contentView = UIView()
|
||
scrollView.addSubview(contentView)
|
||
pinContentView(contentView, to: scrollView)
|
||
|
||
contentView.addSubview(idlePulseView)
|
||
idlePulseView.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
setupContactPanel()
|
||
let contactPanelHeight = contactPanel.heightAnchor.constraint(equalToConstant: 104)
|
||
contactPanelHeight.priority = .defaultHigh
|
||
contactPanelHeight.isActive = true
|
||
let addContactCardHeight = addContactCard.heightAnchor.constraint(equalToConstant: 88)
|
||
addContactCardHeight.priority = .defaultHigh
|
||
addContactCardHeight.isActive = true
|
||
contactStateStack.axis = .vertical
|
||
contactStateStack.alignment = .fill
|
||
contactStateStack.distribution = .fill
|
||
contactStateStack.addArrangedSubview(contactPanel)
|
||
contactStateStack.addArrangedSubview(addContactCard)
|
||
contentView.addSubview(contactStateStack)
|
||
contactStateStack.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
let pulseWidth = idlePulseView.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -30)
|
||
pulseWidth.priority = .defaultHigh
|
||
NSLayoutConstraint.activate([
|
||
idlePulseView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 15),
|
||
idlePulseView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
||
pulseWidth,
|
||
idlePulseView.widthAnchor.constraint(lessThanOrEqualToConstant: 345),
|
||
idlePulseView.heightAnchor.constraint(equalTo: idlePulseView.widthAnchor),
|
||
|
||
contactStateStack.topAnchor.constraint(equalTo: idlePulseView.bottomAnchor, constant: 18),
|
||
contactStateStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15),
|
||
contactStateStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15),
|
||
contactStateStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20)
|
||
])
|
||
}
|
||
|
||
private func setupContactPanel() {
|
||
contactPanel.backgroundColor = UIColor.white.withAlphaComponent(0.95)
|
||
contactPanel.layer.cornerRadius = 14
|
||
contactPanel.layer.shadowColor = UIColor.black.cgColor
|
||
contactPanel.layer.shadowOpacity = 0.025
|
||
contactPanel.layer.shadowRadius = 8
|
||
contactPanel.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||
|
||
let contactIcon = UIImageView(image: UIImage(named: "SOS/sos_contact_alert"))
|
||
contactIcon.contentMode = .scaleAspectFit
|
||
contactPanel.addSubview(contactIcon)
|
||
contactIcon.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
let titleLabel = UILabel()
|
||
titleLabel.text = "紧急联系人"
|
||
titleLabel.textColor = UIColor(hexStr: "#273246")
|
||
titleLabel.font = .systemFont(ofSize: 14, weight: .semibold)
|
||
contactPanel.addSubview(titleLabel)
|
||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
let arrow = UIImageView(image: UIImage(named: "SOS/sos_arrow_right"))
|
||
arrow.contentMode = .scaleAspectFit
|
||
contactPanel.addSubview(arrow)
|
||
arrow.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
contactScrollView.showsHorizontalScrollIndicator = false
|
||
contactScrollView.alwaysBounceHorizontal = true
|
||
contactPanel.addSubview(contactScrollView)
|
||
contactScrollView.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
contactStack.axis = .horizontal
|
||
contactStack.alignment = .fill
|
||
contactStack.distribution = .fill
|
||
contactStack.spacing = 10
|
||
contactScrollView.addSubview(contactStack)
|
||
contactStack.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleManageContacts))
|
||
contactPanel.addGestureRecognizer(tap)
|
||
|
||
NSLayoutConstraint.activate([
|
||
contactIcon.topAnchor.constraint(equalTo: contactPanel.topAnchor, constant: 13),
|
||
contactIcon.leadingAnchor.constraint(equalTo: contactPanel.leadingAnchor, constant: 12),
|
||
contactIcon.widthAnchor.constraint(equalToConstant: 16),
|
||
contactIcon.heightAnchor.constraint(equalToConstant: 16),
|
||
|
||
titleLabel.centerYAnchor.constraint(equalTo: contactIcon.centerYAnchor),
|
||
titleLabel.leadingAnchor.constraint(equalTo: contactIcon.trailingAnchor, constant: 8),
|
||
|
||
arrow.centerYAnchor.constraint(equalTo: contactIcon.centerYAnchor),
|
||
arrow.trailingAnchor.constraint(equalTo: contactPanel.trailingAnchor, constant: -12),
|
||
arrow.widthAnchor.constraint(equalToConstant: 14),
|
||
arrow.heightAnchor.constraint(equalToConstant: 14),
|
||
|
||
contactScrollView.topAnchor.constraint(equalTo: contactIcon.bottomAnchor, constant: 8),
|
||
contactScrollView.leadingAnchor.constraint(equalTo: contactPanel.leadingAnchor, constant: 12),
|
||
contactScrollView.trailingAnchor.constraint(equalTo: contactPanel.trailingAnchor, constant: -12),
|
||
contactScrollView.bottomAnchor.constraint(equalTo: contactPanel.bottomAnchor, constant: -9),
|
||
contactScrollView.heightAnchor.constraint(equalToConstant: 46),
|
||
|
||
contactStack.topAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.topAnchor),
|
||
contactStack.leadingAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.leadingAnchor),
|
||
contactStack.trailingAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.trailingAnchor),
|
||
contactStack.bottomAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.bottomAnchor),
|
||
contactStack.heightAnchor.constraint(equalTo: contactScrollView.frameLayoutGuide.heightAnchor)
|
||
])
|
||
}
|
||
|
||
private func makeAddContactCard() -> UIControl {
|
||
let control = UIControl()
|
||
control.backgroundColor = UIColor.white.withAlphaComponent(0.95)
|
||
control.layer.cornerRadius = 14
|
||
control.layer.shadowColor = UIColor.black.cgColor
|
||
control.layer.shadowOpacity = 0.025
|
||
control.layer.shadowRadius = 8
|
||
control.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||
control.addTarget(self, action: #selector(handleManageContacts), for: .touchUpInside)
|
||
|
||
let iconView = UIImageView(image: UIImage(named: "SOS/add"))
|
||
iconView.contentMode = .scaleAspectFit
|
||
iconView.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
let label = UILabel()
|
||
label.text = "添加紧急联系人"
|
||
label.textColor = UIColor(hexStr: "#999999")
|
||
label.font = .systemFont(ofSize: 14, weight: .regular)
|
||
|
||
let contentStack = UIStackView(arrangedSubviews: [iconView, label])
|
||
contentStack.axis = .horizontal
|
||
contentStack.alignment = .center
|
||
contentStack.spacing = 8
|
||
control.addSubview(contentStack)
|
||
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
NSLayoutConstraint.activate([
|
||
contentStack.centerXAnchor.constraint(equalTo: control.centerXAnchor),
|
||
contentStack.centerYAnchor.constraint(equalTo: control.centerYAnchor),
|
||
iconView.widthAnchor.constraint(equalToConstant: 30),
|
||
iconView.heightAnchor.constraint(equalToConstant: 30)
|
||
])
|
||
return control
|
||
}
|
||
|
||
private func setupCountdownView() {
|
||
let scrollView = makeStateScrollView(in: countdownView)
|
||
let contentView = UIView()
|
||
scrollView.addSubview(contentView)
|
||
pinContentView(contentView, to: scrollView, minimumHeight: true)
|
||
|
||
contentView.addSubview(countdownRingView)
|
||
countdownRingView.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
countdownTitleLabel.attributedText = Self.cancelTitle
|
||
countdownTitleLabel.textAlignment = .center
|
||
countdownTitleLabel.font = .systemFont(ofSize: 22, weight: .regular)
|
||
contentView.addSubview(countdownTitleLabel)
|
||
countdownTitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
countdownBodyLabel.text = "10秒后,会将你的SOS和位置发送到你的圈子和紧急联系人。"
|
||
countdownBodyLabel.textColor = UIColor(hexStr: "#273246")
|
||
countdownBodyLabel.font = .systemFont(ofSize: 15, weight: .regular)
|
||
countdownBodyLabel.textAlignment = .center
|
||
countdownBodyLabel.numberOfLines = 0
|
||
contentView.addSubview(countdownBodyLabel)
|
||
countdownBodyLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
contentView.addSubview(countdownSlider)
|
||
countdownSlider.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
let ringWidth = countdownRingView.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -30)
|
||
ringWidth.priority = .defaultHigh
|
||
let sliderTop = countdownSlider.topAnchor.constraint(greaterThanOrEqualTo: countdownBodyLabel.bottomAnchor, constant: 28)
|
||
NSLayoutConstraint.activate([
|
||
countdownRingView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 15),
|
||
countdownRingView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
||
ringWidth,
|
||
countdownRingView.widthAnchor.constraint(lessThanOrEqualToConstant: 345),
|
||
countdownRingView.heightAnchor.constraint(equalTo: countdownRingView.widthAnchor),
|
||
|
||
countdownTitleLabel.topAnchor.constraint(equalTo: countdownRingView.bottomAnchor, constant: 8),
|
||
countdownTitleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
||
|
||
countdownBodyLabel.topAnchor.constraint(equalTo: countdownTitleLabel.bottomAnchor, constant: 24),
|
||
countdownBodyLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 40),
|
||
countdownBodyLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -40),
|
||
|
||
sliderTop,
|
||
countdownSlider.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 30),
|
||
countdownSlider.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -30),
|
||
countdownSlider.heightAnchor.constraint(equalToConstant: 56),
|
||
countdownSlider.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -(kSafeBottomMargin + 12))
|
||
])
|
||
}
|
||
|
||
private func setupActiveView() {
|
||
let scrollView = makeStateScrollView(in: activeView)
|
||
let contentView = UIView()
|
||
scrollView.addSubview(contentView)
|
||
pinContentView(contentView, to: scrollView, minimumHeight: true)
|
||
|
||
contentView.addSubview(activePulseView)
|
||
activePulseView.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
let titleLabel = UILabel()
|
||
titleLabel.attributedText = Self.cancelTitle
|
||
titleLabel.font = .systemFont(ofSize: 22, weight: .regular)
|
||
titleLabel.textAlignment = .center
|
||
contentView.addSubview(titleLabel)
|
||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
contentView.addSubview(activeSlider)
|
||
activeSlider.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
let pulseWidth = activePulseView.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -30)
|
||
pulseWidth.priority = .defaultHigh
|
||
NSLayoutConstraint.activate([
|
||
activePulseView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 15),
|
||
activePulseView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
||
pulseWidth,
|
||
activePulseView.widthAnchor.constraint(lessThanOrEqualToConstant: 345),
|
||
activePulseView.heightAnchor.constraint(equalTo: activePulseView.widthAnchor),
|
||
|
||
titleLabel.topAnchor.constraint(equalTo: activePulseView.bottomAnchor, constant: 8),
|
||
titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
||
|
||
activeSlider.topAnchor.constraint(greaterThanOrEqualTo: titleLabel.bottomAnchor, constant: 40),
|
||
activeSlider.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 30),
|
||
activeSlider.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -30),
|
||
activeSlider.heightAnchor.constraint(equalToConstant: 56),
|
||
activeSlider.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -(kSafeBottomMargin + 12))
|
||
])
|
||
}
|
||
|
||
private func setupActions() {
|
||
idlePulseView.onTap = { [weak self] in
|
||
guard let self, self.state == .idle else { return }
|
||
self.setState(.countdown)
|
||
}
|
||
countdownRingView.onCompleted = { [weak self] in
|
||
guard let self, self.state == .countdown else { return }
|
||
self.setState(.sending)
|
||
self.onCountdownCompleted?()
|
||
}
|
||
countdownSlider.onCompleted = { [weak self] in
|
||
guard let self, self.state == .countdown else { return }
|
||
self.countdownRingView.stop()
|
||
self.setState(.idle)
|
||
}
|
||
activeSlider.onCompleted = { [weak self] in
|
||
guard let self, self.state == .active else { return }
|
||
self.activeSlider.setInteractionEnabled(false)
|
||
self.onCancelActive?()
|
||
}
|
||
}
|
||
|
||
private func makeStateScrollView(in container: UIView) -> UIScrollView {
|
||
let scrollView = UIScrollView()
|
||
scrollView.showsVerticalScrollIndicator = false
|
||
scrollView.alwaysBounceVertical = false
|
||
container.addSubview(scrollView)
|
||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([
|
||
scrollView.topAnchor.constraint(equalTo: container.topAnchor),
|
||
scrollView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||
scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||
scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor)
|
||
])
|
||
return scrollView
|
||
}
|
||
|
||
private func pinContentView(_ contentView: UIView, to scrollView: UIScrollView, minimumHeight: Bool = false) {
|
||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||
var constraints = [
|
||
contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
|
||
contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
|
||
contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
|
||
contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
|
||
contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor)
|
||
]
|
||
if minimumHeight {
|
||
constraints.append(contentView.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor))
|
||
}
|
||
NSLayoutConstraint.activate(constraints)
|
||
}
|
||
|
||
@objc private func handleManageContacts() {
|
||
onManageContacts?()
|
||
}
|
||
|
||
private static var cancelTitle: NSAttributedString {
|
||
let text = NSMutableAttributedString(
|
||
string: "滑动取消",
|
||
attributes: [.foregroundColor: UIColor(hexStr: "#333333")]
|
||
)
|
||
text.addAttribute(
|
||
.foregroundColor,
|
||
value: UIColor(hexStr: "#FF383C"),
|
||
range: NSRange(location: 2, length: 2)
|
||
)
|
||
return text
|
||
}
|
||
}
|
||
|
||
private final class SOSEmergencyContactCard: UIView {
|
||
|
||
private let phoneBadgeView = UIImageView(image: UIImage(named: "SOS/sos_phone_badge"))
|
||
private let nameLabel = UILabel()
|
||
private let phoneLabel = UILabel()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .white
|
||
layer.cornerRadius = 14
|
||
layer.borderWidth = 0.5
|
||
layer.borderColor = UIColor(hexStr: "#EDEDED").cgColor
|
||
|
||
phoneBadgeView.contentMode = .scaleAspectFit
|
||
addSubview(phoneBadgeView)
|
||
phoneBadgeView.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
nameLabel.textColor = UIColor(hexStr: "#273246")
|
||
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||
nameLabel.lineBreakMode = .byTruncatingTail
|
||
addSubview(nameLabel)
|
||
nameLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
phoneLabel.textColor = UIColor(hexStr: "#888888")
|
||
phoneLabel.font = .systemFont(ofSize: 10, weight: .regular)
|
||
phoneLabel.adjustsFontSizeToFitWidth = true
|
||
phoneLabel.minimumScaleFactor = 0.72
|
||
addSubview(phoneLabel)
|
||
phoneLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
NSLayoutConstraint.activate([
|
||
phoneBadgeView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
|
||
phoneBadgeView.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor),
|
||
phoneBadgeView.widthAnchor.constraint(equalToConstant: 12),
|
||
phoneBadgeView.heightAnchor.constraint(equalToConstant: 12),
|
||
|
||
nameLabel.topAnchor.constraint(equalTo: topAnchor, constant: 7),
|
||
nameLabel.leadingAnchor.constraint(equalTo: phoneBadgeView.trailingAnchor, constant: 5),
|
||
nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
|
||
|
||
phoneLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 2),
|
||
phoneLabel.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor),
|
||
phoneLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4)
|
||
])
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func configure(_ contact: EmergencyContactModel) {
|
||
nameLabel.text = contact.name.isEmpty ? "未命名" : contact.name
|
||
phoneLabel.text = Self.maskedPhone(contact.phone)
|
||
}
|
||
|
||
private static func maskedPhone(_ phone: String) -> String {
|
||
let digits = phone.filter { $0.isNumber }
|
||
guard digits.count >= 7 else { return phone }
|
||
return "\(digits.prefix(3))****\(digits.suffix(4))"
|
||
}
|
||
}
|
||
|
||
private final class SOSActiveBackgroundView: UIView {
|
||
|
||
private let gradientLayer = CAGradientLayer()
|
||
private static let flashAnimationKey = "sos.active.background.flash"
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
gradientLayer.type = .radial
|
||
gradientLayer.colors = [
|
||
UIColor.white.cgColor,
|
||
UIColor(hexStr: "#FFF5F5").cgColor,
|
||
UIColor(hexStr: "#FFB7BA").cgColor
|
||
]
|
||
gradientLayer.locations = [0, 0.58, 1]
|
||
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
|
||
gradientLayer.endPoint = CGPoint(x: 1, y: 1)
|
||
layer.addSublayer(gradientLayer)
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
gradientLayer.frame = bounds
|
||
}
|
||
|
||
func startFlashing() {
|
||
guard gradientLayer.animation(forKey: Self.flashAnimationKey) == nil else { return }
|
||
|
||
let animation = CAKeyframeAnimation(keyPath: "opacity")
|
||
animation.values = [0.42, 1.0, 0.42]
|
||
animation.keyTimes = [0, 0.5, 1]
|
||
animation.duration = 1.15
|
||
animation.repeatCount = .infinity
|
||
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||
animation.isRemovedOnCompletion = false
|
||
gradientLayer.add(animation, forKey: Self.flashAnimationKey)
|
||
}
|
||
|
||
func stopFlashing() {
|
||
gradientLayer.removeAnimation(forKey: Self.flashAnimationKey)
|
||
gradientLayer.opacity = 1
|
||
}
|
||
}
|