// // InstallLiveActivity.swift // FTStoreActivity // // Lock-screen banner + Dynamic Island for the download → sign → install // lifecycle. Renders `InstallActivityAttributes` (shared with the app target), // which describes every install in flight rather than one of them. // // This is the app's widget with its pause button taken out: the Manager's // downloads cannot be held, so the island here is read-only on every iOS // version rather than only below 17. // import WidgetKit import SwiftUI import UIKit import ActivityKit /// FTStore brand purple (#848ef9). Defined locally so this target needs no /// dependency on the app's colour helpers. private let ftBrandPurple = Color(red: 132 / 255, green: 142 / 255, blue: 249 / 255) @available(iOS 16.1, *) private func installFraction(_ state: InstallActivityAttributes.ContentState) -> Double { min(max(state.progress, 0), 1) } @available(iOS 16.1, *) private func installPercent(_ state: InstallActivityAttributes.ContentState) -> Int { Int((installFraction(state) * 100).rounded()) } /// A number only means something while bytes are arriving. /// /// Signing and installing have no measurable progress, and the manager leaves /// the download's last value in place while they run — so printing it there /// showed a figure that had stopped moving next to a label that had changed. @available(iOS 16.1, *) private func showsPercent(_ state: InstallActivityAttributes.ContentState) -> Bool { state.isDownloading } /// The phase glyph: whatever the first install is doing, since it is the one /// whose icon and name the compact presentations show. @available(iOS 16.1, *) private func batchSystemImage(_ state: InstallActivityAttributes.ContentState) -> String { state.items.first?.phase.systemImage ?? InstallActivityAttributes.Phase.downloading.systemImage } /// The headline: one app by name, or how many there are. /// /// Names rather than "Downloads" while there is only one, because that is the /// question the island is being glanced at to answer. Past one, the count is the /// answer and the names are on the line below. @available(iOS 16.1, *) private func batchTitle(_ state: InstallActivityAttributes.ContentState) -> String { let total = state.items.count + state.hiddenCount if total <= 1 { return state.items.first?.name ?? NSLocalizedString( "Downloading", comment: "Install phase, shown on the Live Activity" ) } return String( format: NSLocalizedString("%lld apps", comment: "How many apps are being installed at once"), total ) } /// The line under the headline: what is being done, in as few words as it takes. /// One install names its phase; several name themselves. @available(iOS 16.1, *) private func batchSubtitle(_ state: InstallActivityAttributes.ContentState) -> String { let total = state.items.count + state.hiddenCount if total <= 1 { guard let phase = state.items.first?.phase else { return "" } return NSLocalizedString(phase.label, comment: "Install phase, shown on the Live Activity") } let names = state.items.map(\.name).joined(separator: ", ") guard state.hiddenCount > 0 else { return names } return names + "…" } /// Transfer rate and bytes, in one line, separated the way the rest of the app /// separates two facts side by side. @available(iOS 16.1, *) private func batchDetail(_ state: InstallActivityAttributes.ContentState) -> String? { guard state.isDownloading else { return nil } var parts: [String] = [] if state.bytesPerSecond > 0 { parts.append(formatBytes(state.bytesPerSecond) + "/s") } if state.bytesExpected > 0 { parts.append("\(formatBytes(state.bytesDownloaded)) / \(formatBytes(state.bytesExpected))") } guard !parts.isEmpty else { return nil } return parts.joined(separator: " · ") } private func formatBytes(_ bytes: Int64) -> String { let formatter = ByteCountFormatter() formatter.countStyle = .binary formatter.allowedUnits = [.useKB, .useMB, .useGB] formatter.includesUnit = true formatter.isAdaptive = true return formatter.string(fromByteCount: max(bytes, 0)) } /// The download bar: a thin capsule that fills as the bytes arrive. /// /// A percentage on its own is a number to read; a bar is a thing to glance at, /// which is the whole job of a Live Activity. It also gives the expanded island /// something to do with the width between the icon and the number, which was /// empty. /// /// Absent outside the downloading phase rather than shown full or shown empty: /// either would claim to measure something that is not being measured. @available(iOS 16.1, *) private struct InstallProgressBar: View { let state: InstallActivityAttributes.ContentState var height: CGFloat = 5 var body: some View { if showsPercent(state) { GeometryReader { geometry in ZStack(alignment: .leading) { Capsule() .fill(ftBrandPurple.opacity(0.22)) Capsule() .fill(ftBrandPurple) .frame(width: geometry.size.width * installFraction(state)) } } .frame(height: height) // The bar is the one thing here that moves on its own, so it is also // the one thing worth animating between updates. .animation(.easeOut(duration: 0.3), value: state.progress) } } } /// The same reading as the bar, in the space a compact island has. /// /// A ring rather than three characters: at this size a shape that is a quarter /// full is read instantly, and "29%" has to be focused on to be read at all. @available(iOS 16.1, *) private struct InstallProgressRing: View { let state: InstallActivityAttributes.ContentState var size: CGFloat = 18 var lineWidth: CGFloat = 2.5 /// Drawn inside the ring. The count when there is one to report, so a glance /// at the notch says how many apps are coming rather than only how far along /// they are. var showsCount: Bool = false private var total: Int { state.items.count + state.hiddenCount } var body: some View { ZStack { // Both rings are drawn *inside* the frame, which is why the track // uses `strokeBorder` and the progress arc insets before it strokes. // // `stroke` centres the line on the circle's path, so half the line // width sits outside the frame: an 18 point ring with a 2.5 point // line actually covers 20.5. Nothing in the app noticed, because // everywhere else there is room for the overhang. The compact region // of the Dynamic Island is the one place there is not. It masks its // content to the island's own rounded shape, hard against the // camera, and the overhang was being cut off there. Circle() .strokeBorder(ftBrandPurple.opacity(0.25), lineWidth: lineWidth) Circle() .inset(by: lineWidth / 2) .trim(from: 0, to: installFraction(state)) .stroke(ftBrandPurple, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) // From the top, clockwise, which is the direction everything // else that fills up in iOS goes. .rotationEffect(.degrees(-90)) .animation(.easeOut(duration: 0.3), value: state.progress) if showsCount, total > 1 { Text(verbatim: "\(total)") .font(.system(size: size * 0.5, weight: .bold).monospacedDigit()) .foregroundStyle(ftBrandPurple) } } .frame(width: size, height: size) } } /// Leading visual: the installed app's icon when we have it, otherwise the phase /// glyph. Shared by every presentation (compact / minimal / expanded / lock /// screen) so the fallback behaves identically everywhere. @available(iOS 16.1, *) private struct InstallLeadingIcon: View { let state: InstallActivityAttributes.ContentState var iconSize: CGFloat var symbolFont: Font? = nil var body: some View { if let data = state.iconData, let image = UIImage(data: data) { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fill) .frame(width: iconSize, height: iconSize) .clipShape(RoundedRectangle(cornerRadius: iconSize * 0.22, style: .continuous)) } else { Image(systemName: batchSystemImage(state)) .font(symbolFont) .foregroundStyle(ftBrandPurple) } } } /// Time left, counted down by the system rather than by our updates. /// /// `Text(timerInterval:)` keeps ticking on its own between pushes, which is what /// makes a figure this volatile worth showing at all: a pushed "4m left" is /// wrong a second after it arrives, and we push at most once a second. @available(iOS 16.1, *) private struct InstallCountdown: View { let state: InstallActivityAttributes.ContentState var body: some View { if let range = _range { HStack(spacing: 3) { Image(systemName: "clock") Text(timerInterval: range, countsDown: true) .monospacedDigit() } } } /// One `Date()` for both ends of the test and the range. Reading the clock /// twice can put `now` past `finishAt` between them, and a `ClosedRange` /// whose lower bound is above its upper one traps. private var _range: ClosedRange? { guard state.isDownloading, let finishAt = state.finishAt else { return nil } let now = Date() guard finishAt > now else { return nil } return now...finishAt } } @available(iOS 16.1, *) struct InstallLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: InstallActivityAttributes.self) { context in InstallLockScreenView(context: context) .activitySystemActionForegroundColor(ftBrandPurple) } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { // Inset from the island's rounded corner so the icon isn't clipped. InstallLeadingIcon(state: context.state, iconSize: 28, symbolFont: .title2) .padding(.leading, 6) } DynamicIslandExpandedRegion(.trailing) { // The number and nothing else. This region is only as wide as // the camera leaves it. if showsPercent(context.state) { Text(verbatim: "\(installPercent(context.state))%") .font(.title3.monospacedDigit().weight(.semibold)) .foregroundStyle(ftBrandPurple) .padding(.trailing, 6) } } DynamicIslandExpandedRegion(.bottom) { VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 2) { Text(batchTitle(context.state)) .font(.headline) .lineLimit(1) Text(batchSubtitle(context.state)) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } // Across the full width, under both the icon and the // number: it is the one element that relates them, and // the space it fills was empty. InstallProgressBar(state: context.state) if let detail = batchDetail(context.state) { HStack(spacing: 6) { Text(detail) .monospacedDigit() Spacer(minLength: 4) InstallCountdown(state: context.state) } .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 6) .padding(.bottom, 4) } } compactLeading: { // App icon on the left (falls back to the phase glyph). InstallLeadingIcon(state: context.state, iconSize: 22) } compactTrailing: { if showsPercent(context.state) { InstallProgressRing(state: context.state, showsCount: true) } else { // Not left empty: a compact island with only a leading item // sits lopsided against the camera, and the phase glyph is // the honest thing to put there once there is no progress to // report. Image(systemName: batchSystemImage(context.state)) .font(.caption) .foregroundStyle(ftBrandPurple) } } minimal: { // The ring wins over the icon here: minimal is what iOS falls back // to when something else wants the island too, and a bare icon // there says an install exists without saying anything about it. InstallProgressRing(state: context.state, size: 22, lineWidth: 3, showsCount: true) } // The hairline around the island picks up the same tint, so the whole // thing reads as one object before anything inside it is legible. .keylineTint(ftBrandPurple) } } } @available(iOS 16.1, *) private struct InstallLockScreenView: View { let context: ActivityViewContext var body: some View { VStack(spacing: 10) { HStack(spacing: 12) { InstallLeadingIcon(state: context.state, iconSize: 40, symbolFont: .title) .frame(width: 40) VStack(alignment: .leading, spacing: 2) { Text(batchTitle(context.state)) .font(.headline) .lineLimit(1) Text(batchSubtitle(context.state)) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } Spacer() if showsPercent(context.state) { Text(verbatim: "\(installPercent(context.state))%") .font(.title2.monospacedDigit().weight(.semibold)) .foregroundStyle(ftBrandPurple) } } // Full width and under everything, so the lock screen reads the same // way the expanded island does rather than being its own design. InstallProgressBar(state: context.state, height: 6) if let detail = batchDetail(context.state) { HStack(spacing: 6) { Text(detail) .monospacedDigit() Spacer(minLength: 4) InstallCountdown(state: context.state) } .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } } .padding() } }