Files
FTStore/ios/FTStoreActivity/InstallLiveActivity.swift
Felitendo d1333906bd fix: keep the progress ring inside the frame it claims
The ring in the Dynamic Island was drawn with `stroke`, which centres the line
on the circle's path. Half the line width therefore falls outside the frame: an
18 point ring with a 2.5 point line covers 20.5 points and reports 18. Nowhere
else does that matter, because there is room around it for the overhang to land
in. The compact region of the island is the one place there is not. It masks
what it holds to the island's own rounded shape, hard against the camera, and
the part of the ring that was never accounted for got cut off against it.

Both rings are now drawn inside the frame, the track through `strokeBorder` and
the arc by insetting half a line width before stroking. The drawn size is the
declared size, so there is nothing left over to clip, and the minimal
presentation gains the same fix, where the overhang was larger still.

The floating circle in the app has always drawn this way. This was the one ring
that did not.
2026-08-26 16:26:37 +02:00

455 lines
15 KiB
Swift

//
// 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.
//
import WidgetKit
import SwiftUI
import UIKit
import ActivityKit
import AppIntents
/// 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)
/// Held downloads read in the warning tint everywhere, so the one glance the
/// island gets is enough to tell "stopped" from "slow".
private let ftPausedTint = Color.orange
@available(iOS 16.1, *)
private func installTint(_ state: InstallActivityAttributes.ContentState) -> Color {
state.isPaused ? ftPausedTint : ftBrandPurple
}
@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 for the batch: 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 {
if state.isPaused { return "pause.fill" }
return 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 the batch is doing, 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 {
if state.isPaused {
return NSLocalizedString("Paused", comment: "Every download is held")
}
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, !state.isPaused 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(installTint(state).opacity(0.22))
Capsule()
.fill(installTint(state))
.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 batch 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.
//
// The floating circle inside the app already draws this way
// (`InstallProgressView`); this was the one ring that did not.
Circle()
.strokeBorder(installTint(state).opacity(0.25), lineWidth: lineWidth)
Circle()
.inset(by: lineWidth / 2)
.trim(from: 0, to: installFraction(state))
.stroke(installTint(state), 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 state.isPaused {
Image(systemName: "pause.fill")
.font(.system(size: size * 0.42))
.foregroundStyle(ftPausedTint)
} else 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 downloaded 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(installTint(state))
}
}
}
/// Hold or release the whole batch without opening the app.
///
/// iOS 17 is when a Live Activity gained the ability to carry a button at all,
/// so on 16 this is simply absent and the island stays the read-only thing it
/// has always been. Nothing else changes shape around it.
@available(iOS 16.1, *)
private struct InstallPauseButton: View {
let state: InstallActivityAttributes.ContentState
var size: CGFloat = 30
var body: some View {
// Nested rather than `if #available(…), state.isDownloading` on one line:
// the availability check has to be the whole condition for the compiler to
// narrow the branch, and the intents below are iOS 17 types.
if #available(iOS 17.0, *) {
if state.isDownloading {
if state.isPaused {
Button(intent: ResumeDownloadsIntent()) {
_label("play.fill")
}
.buttonStyle(.plain)
} else {
Button(intent: PauseDownloadsIntent()) {
_label("pause.fill")
}
.buttonStyle(.plain)
}
}
}
}
private func _label(_ systemImage: String) -> some View {
Image(systemName: systemImage)
.font(.system(size: size * 0.4, weight: .semibold))
.foregroundStyle(installTint(state))
.frame(width: size, height: size)
.background(installTint(state).opacity(0.18))
.clipShape(Circle())
}
}
/// 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<Date>? {
guard
state.isDownloading,
!state.isPaused,
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, so the pause button goes in the bottom
// region below, which has the whole width.
if showsPercent(context.state) {
Text(verbatim: "\(installPercent(context.state))%")
.font(.title3.monospacedDigit().weight(.semibold))
.foregroundStyle(installTint(context.state))
.padding(.trailing, 6)
}
}
DynamicIslandExpandedRegion(.bottom) {
HStack(alignment: .center, spacing: 10) {
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)
InstallPauseButton(state: context.state, size: 32)
}
.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(installTint(context.state))
}
} 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 a held
// batch reads as held even before anything inside it is legible.
.keylineTint(installTint(context.state))
}
}
}
@available(iOS 16.1, *)
private struct InstallLockScreenView: View {
let context: ActivityViewContext<InstallActivityAttributes>
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(installTint(context.state))
}
InstallPauseButton(state: context.state, size: 34)
}
// 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()
}
}