mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 08:11:03 +00:00
5d05363f36
The app is one binary for iPhone and iPad, so what was built for the iPhone has to hold on an iPad too: - Widgets: the trip, today and bookings widgets offer the iPad's extra large size. The trip sets its countdown or its day beside the bookings or the day's stops, today sets the day, its progress and weather beside the timeline, bookings sets the next one as a card beside the rest. The other widgets stay at the sizes they were designed for. - The widget renderer draws every home-screen size of each widget as a 13" iPad, an 11" iPad and an iPad mini give it (a mini's small widget is smaller than an iPhone's), an iPad lock screen with the accessories and the travel day's Live Activity, and that Live Activity at the iPhone's and the iPad's widths. To draw it there, its lock-screen banner takes plain values (TripDayBanner, now beside the today widget). - Apple Wallet: an iPad cannot take a pass. The shell reports the wallet feature only where PKPassLibrary and PKAddPassesViewController can add one, so the UI shows no "Add to Apple Wallet" button there and hands a .pkpass to the share sheet like any other file. - Share extension: on an iPad it is presented over the full screen instead of in a form sheet, and in a window of regular width its card stands centred over the dimmed app, without the grabber, instead of rising from the bottom edge. A narrow Split View window keeps the iPhone's card.
439 lines
18 KiB
Swift
439 lines
18 KiB
Swift
import SwiftUI
|
|
|
|
/// The sheet: what was shared, the trip it goes to, and one button. It rises over
|
|
/// the app that shared, in the system material, and answers the tap with a
|
|
/// checkmark before TREK comes forward.
|
|
struct ShareView: View {
|
|
@ObservedObject var model: ShareModel
|
|
@Environment(\.colorScheme) private var scheme
|
|
@Environment(\.horizontalSizeClass) private var widthClass
|
|
@State private var shown = false
|
|
|
|
/// An iPad window of regular width: the card stands centred over the dimmed
|
|
/// app, as a form does there, instead of rising from the bottom edge. A
|
|
/// narrow Split View or Slide Over window keeps the iPhone's bottom card.
|
|
private var centred: Bool { widthClass == .regular }
|
|
|
|
var body: some View {
|
|
ZStack(alignment: centred ? .center : .bottom) {
|
|
Color.black.opacity(shown ? (centred ? 0.36 : 0.28) : 0)
|
|
.ignoresSafeArea()
|
|
.onTapGesture { dismiss() }
|
|
|
|
card
|
|
.offset(y: shown ? 0 : (centred ? 40 : 520))
|
|
.opacity(centred && !shown ? 0 : 1)
|
|
.padding(.vertical, centred ? 24 : 0)
|
|
}
|
|
.onAppear {
|
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.86)) { shown = true }
|
|
}
|
|
}
|
|
|
|
private func dismiss() {
|
|
withAnimation(.easeIn(duration: 0.2)) { shown = false }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { model.onCancel() }
|
|
}
|
|
|
|
// MARK: - Card
|
|
|
|
private var card: some View {
|
|
VStack(spacing: 0) {
|
|
// The grabber belongs to a card that rises from the bottom edge.
|
|
if !centred {
|
|
Capsule()
|
|
.fill(ShareStyle.faint(scheme).opacity(0.5))
|
|
.frame(width: 38, height: 5)
|
|
.padding(.top, 8)
|
|
}
|
|
|
|
header
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, centred ? 22 : 14)
|
|
|
|
Group {
|
|
switch model.phase {
|
|
case .failed(let message):
|
|
failure(message)
|
|
default:
|
|
content
|
|
}
|
|
}
|
|
.padding(.top, 16)
|
|
}
|
|
.padding(.bottom, centred ? 20 : 12)
|
|
.frame(maxWidth: 560)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 32, style: .continuous)
|
|
.fill(.regularMaterial)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 32, style: .continuous)
|
|
.strokeBorder(ShareStyle.edge(scheme), lineWidth: 1)
|
|
)
|
|
.shadow(color: .black.opacity(0.25), radius: 30, y: 10)
|
|
.ignoresSafeArea(edges: centred ? [] : .bottom)
|
|
)
|
|
.padding(.horizontal, centred ? 24 : 6)
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 12) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 11, style: .continuous)
|
|
.fill(ShareStyle.ink(scheme))
|
|
Text("T")
|
|
.font(.system(size: 20, weight: .heavy, design: .rounded))
|
|
.foregroundColor(ShareStyle.paper(scheme))
|
|
}
|
|
.frame(width: 38, height: 38)
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text("TREK")
|
|
.font(.system(size: 12, weight: .bold))
|
|
.tracking(2.4)
|
|
.foregroundColor(ShareStyle.muted(scheme))
|
|
Text(L("share.title"))
|
|
.font(.system(size: 19, weight: .bold))
|
|
.foregroundColor(ShareStyle.ink(scheme))
|
|
}
|
|
Spacer()
|
|
Button(action: dismiss) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 13, weight: .bold))
|
|
.foregroundColor(ShareStyle.muted(scheme))
|
|
.frame(width: 32, height: 32)
|
|
.background(Circle().fill(ShareStyle.track(scheme)))
|
|
}
|
|
.accessibilityLabel(L("share.cancel"))
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
itemCard
|
|
.padding(.horizontal, 20)
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(L("share.trip").uppercased())
|
|
.font(.system(size: 12, weight: .bold))
|
|
.tracking(1.2)
|
|
.foregroundColor(ShareStyle.faint(scheme))
|
|
.padding(.horizontal, 22)
|
|
if model.trips.isEmpty {
|
|
Text(L("share.noTrips"))
|
|
.font(.system(size: 14))
|
|
.foregroundColor(ShareStyle.muted(scheme))
|
|
.padding(.horizontal, 22)
|
|
} else {
|
|
tripRail
|
|
}
|
|
}
|
|
|
|
Text(L("share.hint"))
|
|
.font(.system(size: 13))
|
|
.foregroundColor(ShareStyle.faint(scheme))
|
|
.frame(maxWidth: .infinity)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 24)
|
|
|
|
addButton
|
|
.padding(.horizontal, 20)
|
|
}
|
|
}
|
|
|
|
// MARK: - The shared item
|
|
|
|
private var itemCard: some View {
|
|
HStack(spacing: 14) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.fill(LinearGradient(colors: ShareStyle.kindColors(model.item?.kind ?? .file),
|
|
startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
Image(systemName: (model.item?.kind ?? .file).symbol)
|
|
.font(.system(size: 22, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
}
|
|
.frame(width: 54, height: 54)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(model.item?.title ?? L("share.reading"))
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundColor(ShareStyle.ink(scheme))
|
|
.lineLimit(2)
|
|
HStack(spacing: 6) {
|
|
if let item = model.item {
|
|
badge(L("share.kind.\(item.kind.rawValue)"))
|
|
if let source = item.source { badge(source) }
|
|
badge(item.sizeLabel)
|
|
} else {
|
|
ProgressView().scaleEffect(0.8)
|
|
}
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(14)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.fill(ShareStyle.raised(scheme))
|
|
)
|
|
.redacted(reason: model.item == nil ? .placeholder : [])
|
|
}
|
|
|
|
private func badge(_ text: String) -> some View {
|
|
Text(text)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(ShareStyle.muted(scheme))
|
|
.lineLimit(1)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(Capsule().fill(ShareStyle.track(scheme)))
|
|
}
|
|
|
|
// MARK: - Trips
|
|
|
|
private var tripRail: some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 10) {
|
|
ForEach(model.trips) { trip in
|
|
tripCard(trip)
|
|
.id(trip.id)
|
|
}
|
|
laterCard
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 4)
|
|
}
|
|
.onAppear {
|
|
if let id = model.selectedTrip { proxy.scrollTo(id, anchor: .leading) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func tripCard(_ trip: ShareHandoff.Trip) -> some View {
|
|
let selected = model.selectedTrip == trip.id
|
|
return Button {
|
|
UISelectionFeedbackGenerator().selectionChanged()
|
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.7)) { model.selectedTrip = trip.id }
|
|
} label: {
|
|
ZStack(alignment: .bottomLeading) {
|
|
cover(trip)
|
|
LinearGradient(colors: [.clear, .black.opacity(0.7)], startPoint: .center, endPoint: .bottom)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(trip.title)
|
|
.font(.system(size: 15, weight: .bold))
|
|
.foregroundColor(.white)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.leading)
|
|
if let place = trip.place {
|
|
Text(place)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(.white.opacity(0.8))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.padding(11)
|
|
}
|
|
.frame(width: 156, height: 118)
|
|
.overlay(alignment: .topLeading) {
|
|
if let when = relative(trip) {
|
|
Text(when)
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 7)
|
|
.padding(.vertical, 3)
|
|
.background(Capsule().fill(.black.opacity(0.35)))
|
|
.padding(9)
|
|
}
|
|
}
|
|
.overlay(alignment: .topTrailing) {
|
|
if selected {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 22, weight: .semibold))
|
|
.symbolRenderingMode(.palette)
|
|
.foregroundStyle(ShareStyle.ink(.light), .white)
|
|
.padding(7)
|
|
.transition(.scale.combined(with: .opacity))
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.strokeBorder(selected ? ShareStyle.ink(scheme) : .clear, lineWidth: 2.5)
|
|
)
|
|
.scaleEffect(selected ? 1 : 0.96)
|
|
.opacity(selected || model.selectedTrip == nil ? 1 : 0.78)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityAddTraits(selected ? .isSelected : [])
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func cover(_ trip: ShareHandoff.Trip) -> some View {
|
|
if let data = trip.cover, let image = UIImage(data: data) {
|
|
Image(uiImage: image).resizable().scaledToFill()
|
|
} else {
|
|
ZStack {
|
|
LinearGradient(colors: ShareStyle.tripColors(trip.id), startPoint: .topLeading, endPoint: .bottomTrailing)
|
|
Image(systemName: "airplane")
|
|
.font(.system(size: 34, weight: .semibold))
|
|
.foregroundColor(.white.opacity(0.22))
|
|
.rotationEffect(.degrees(-20))
|
|
.offset(x: 40, y: -18)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var laterCard: some View {
|
|
let selected = model.selectedTrip == nil
|
|
return Button {
|
|
UISelectionFeedbackGenerator().selectionChanged()
|
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.7)) { model.selectedTrip = nil }
|
|
} label: {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "rectangle.stack.badge.plus")
|
|
.font(.system(size: 22, weight: .semibold))
|
|
Text(L("share.later"))
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.foregroundColor(ShareStyle.muted(scheme))
|
|
.frame(width: 118, height: 118)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.strokeBorder(selected ? ShareStyle.ink(scheme) : ShareStyle.faint(scheme).opacity(0.5),
|
|
style: StrokeStyle(lineWidth: selected ? 2.5 : 1.5, dash: selected ? [] : [5, 4]))
|
|
)
|
|
.scaleEffect(selected ? 1 : 0.96)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func relative(_ trip: ShareHandoff.Trip) -> String? {
|
|
let calendar = Calendar.current
|
|
let today = calendar.startOfDay(for: Date())
|
|
guard let start = trip.start else { return nil }
|
|
let end = trip.end ?? start
|
|
if today >= calendar.startOfDay(for: start), today <= calendar.startOfDay(for: end) { return L("share.now") }
|
|
if today > calendar.startOfDay(for: end) { return L("share.past") }
|
|
let days = calendar.dateComponents([.day], from: today, to: calendar.startOfDay(for: start)).day ?? 0
|
|
if days == 1 { return L("share.tomorrow") }
|
|
return String(format: L("share.inDays"), days)
|
|
}
|
|
|
|
// MARK: - Button and failure
|
|
|
|
private var addButton: some View {
|
|
Button(action: model.add) {
|
|
ZStack {
|
|
switch model.phase {
|
|
case .saving:
|
|
ProgressView().tint(ShareStyle.paper(scheme))
|
|
case .done:
|
|
Label(L("share.added"), systemImage: "checkmark")
|
|
.font(.system(size: 17, weight: .bold))
|
|
.foregroundColor(.white)
|
|
.transition(.scale(scale: 0.6).combined(with: .opacity))
|
|
default:
|
|
Text(buttonTitle)
|
|
.font(.system(size: 17, weight: .bold))
|
|
.foregroundColor(ShareStyle.paper(scheme))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 54)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 17, style: .continuous)
|
|
.fill(model.phase == .done ? ShareStyle.success : ShareStyle.ink(scheme))
|
|
)
|
|
.scaleEffect(model.phase == .done ? 1.02 : 1)
|
|
}
|
|
.buttonStyle(PressStyle())
|
|
.disabled(model.phase != .ready)
|
|
.opacity(model.phase == .loading ? 0.5 : 1)
|
|
}
|
|
|
|
private var buttonTitle: String {
|
|
if let title = model.selectedTitle { return String(format: L("share.addTo"), title) }
|
|
return L("share.add")
|
|
}
|
|
|
|
private func failure(_ message: String) -> some View {
|
|
VStack(spacing: 14) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.font(.system(size: 30, weight: .semibold))
|
|
.foregroundColor(ShareStyle.warning)
|
|
Text(message)
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundColor(ShareStyle.ink(scheme))
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 28)
|
|
Button(action: dismiss) {
|
|
Text(L("share.close"))
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundColor(ShareStyle.ink(scheme))
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 50)
|
|
.background(RoundedRectangle(cornerRadius: 16, style: .continuous).fill(ShareStyle.track(scheme)))
|
|
}
|
|
.buttonStyle(PressStyle())
|
|
.padding(.horizontal, 20)
|
|
}
|
|
.padding(.vertical, 10)
|
|
}
|
|
}
|
|
|
|
/// A press that gives a little under the thumb.
|
|
private struct PressStyle: ButtonStyle {
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
|
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
/// The app's neutral palette (the same values as TREKWidgets/TrekStyle.swift).
|
|
enum ShareStyle {
|
|
static func ink(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0xF5F5F7) : Color(hex: 0x101013) }
|
|
static func paper(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0x101013) : Color(hex: 0xFFFFFF) }
|
|
static func muted(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0x9C9CA3) : Color(hex: 0x68686F) }
|
|
static func faint(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0x6E6E76) : Color(hex: 0x9A9AA1) }
|
|
static func track(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.1) : Color(hex: 0x101013).opacity(0.06) }
|
|
static func raised(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.06) : Color.white.opacity(0.7) }
|
|
static func edge(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.05) }
|
|
static let success = Color(hex: 0x2FA37A)
|
|
static let warning = Color(hex: 0xE8A13A)
|
|
|
|
static func kindColors(_ kind: ShareItem.Kind) -> [Color] {
|
|
switch kind {
|
|
case .pass: return [Color(hex: 0x1F1F24), Color(hex: 0x3A3A44)]
|
|
case .pdf: return [Color(hex: 0xE5484D), Color(hex: 0xB4232A)]
|
|
case .mail: return [Color(hex: 0x4A9DFF), Color(hex: 0x1F6FEB)]
|
|
case .page: return [Color(hex: 0x38B2F5), Color(hex: 0x1A7FD6)]
|
|
case .text: return [Color(hex: 0x8E8E96), Color(hex: 0x5D5D66)]
|
|
case .image: return [Color(hex: 0xF2A93B), Color(hex: 0xE0662E)]
|
|
case .file: return [Color(hex: 0x7C7C86), Color(hex: 0x4D4D57)]
|
|
}
|
|
}
|
|
|
|
/// A trip without a cover gets a gradient of its own, steady across opens.
|
|
static func tripColors(_ id: Int) -> [Color] {
|
|
let base = Double((id &* 47) % 360) / 360
|
|
return [
|
|
Color(hue: base, saturation: 0.6, brightness: 0.78),
|
|
Color(hue: (base + 0.11).truncatingRemainder(dividingBy: 1), saturation: 0.7, brightness: 0.42),
|
|
]
|
|
}
|
|
}
|
|
|
|
private extension Color {
|
|
init(hex: UInt32) {
|
|
self.init(red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255, blue: Double(hex & 0xFF) / 255)
|
|
}
|
|
}
|