-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathOffWorkWidgets.swift
More file actions
904 lines (839 loc) · 39.3 KB
/
Copy pathOffWorkWidgets.swift
File metadata and controls
904 lines (839 loc) · 39.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
import ActivityKit
import AppIntents
import SwiftUI
import UIKit
import WidgetKit
@main
struct OffWorkWidgets: WidgetBundle {
var body: some Widget {
OffWorkCountdownWidget()
OffWorkLiveActivityWidget()
}
}
struct OffWorkLiveActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: OffWorkActivityAttributes.self) { context in
LockScreenActivityView(context: context)
.environment(\.colorScheme, .dark)
.activityBackgroundTint(.black.opacity(0.38))
.activitySystemActionForegroundColor(.white)
.widgetURL(activityDestination(context))
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading, priority: 1) {
ActivityIslandHeading(context: context)
}
DynamicIslandExpandedRegion(.trailing) {
ActivityIslandEndTime(context: context)
}
DynamicIslandExpandedRegion(.bottom) {
ActivityIslandBody(context: context)
}
} compactLeading: {
ActivityCompactGlyph(context: context, size: 13, brandSize: 20)
} compactTrailing: {
ActivityCompactCountdown(context: context)
} minimal: {
// The one slot another app's activity can squeeze this into.
// The ring is the system's own idiom for something running, and
// the glyph stays inside it because it is still all that says
// which of ours this is.
ActivityMinimalRing(context: context)
}
.widgetURL(activityDestination(context))
.keylineTint(activityTint(context, at: .now))
}
}
}
// MARK: - Dynamic Island
private struct ActivityIslandHeading: View {
let context: ActivityViewContext<OffWorkActivityAttributes>
var body: some View {
TimelineView(activitySchedule(context)) { timeline in
HStack(spacing: 8) {
// The bare mark, not the plated app icon: the Dynamic Island
// is already a container, and a second rounded plate inside it
// reads as a sticker. Focus and break activities show their own
// glyph instead — two activities wearing the same mark are two
// the user cannot tell apart.
if let symbol = activitySymbol(context, at: timeline.date) {
Image(systemName: symbol)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(activityTint(context, at: timeline.date))
.frame(width: 25, height: 25)
} else {
AlwaysDarkBrandMark(size: 25)
}
Text(activityPhaseLabel(context, at: timeline.date))
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.9))
.lineLimit(1)
.minimumScaleFactor(0.85)
}
.frame(maxWidth: 150, alignment: .leading)
.padding(.leading, 8)
}
}
}
private struct ActivityIslandEndTime: View {
let context: ActivityViewContext<OffWorkActivityAttributes>
var body: some View {
TimelineView(activitySchedule(context)) { timeline in
// The capsule rounds the corners of the expanded area, so the
// trailing content is pulled in far enough that its last glyph is
// not clipped by the curve.
if !activityComplete(context, at: timeline.date) {
Text(activityEnd(context, at: timeline.date), style: .time)
.font(.system(size: 14).monospacedDigit())
.foregroundStyle(.white.opacity(0.8))
.environment(\.locale, activityLocale(context))
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.padding(.trailing, 8)
}
}
}
}
private struct ActivityIslandBody: View {
let context: ActivityViewContext<OffWorkActivityAttributes>
var body: some View {
TimelineView(activitySchedule(context)) { timeline in
VStack(alignment: .leading, spacing: 4) {
// A task name needs the width below the camera, not the narrow
// leading slot and an unreadable shrink.
if let title = activityTaskTitle(context, at: timeline.date) {
Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
.fixedSize(horizontal: false, vertical: true)
}
ActivityCountdownPanel(
context: context,
now: timeline.date,
size: 36
)
if let support = activitySupportLine(context, at: timeline.date) {
Text(support)
.font(.system(size: 13))
.foregroundStyle(.white.opacity(0.75))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
}
.padding(.horizontal, 10)
.padding(.bottom, 12)
}
}
}
// MARK: - Lock Screen
private struct LockScreenActivityView: View {
let context: ActivityViewContext<OffWorkActivityAttributes>
@Environment(\.isLuminanceReduced) private var isLuminanceReduced
var body: some View {
TimelineView(activitySchedule(context)) { timeline in
let leg = activityLeg(context, at: timeline.date)
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 10) {
// The lock-screen card has enough room for the full icon and
// benefits from its solid plate against the glass material.
// Dynamic Island remains on the lighter transparent mark.
ActivityAppIcon(size: 24)
Text(activityTitle(context, at: timeline.date))
.lineLimit(1)
.minimumScaleFactor(0.8)
.font(.system(size: 13, weight: .semibold))
.tracking(0.25)
.foregroundStyle(.secondary)
Spacer()
// A block nobody has started has no end worth printing —
// its hero already shows the hour it begins.
if leg?.isPreview != true, !activityComplete(context, at: timeline.date) {
Group {
if let finishNote = leg?.finishNote {
Text(finishNote)
} else {
Text(activityEnd(context, at: timeline.date), style: .time)
}
}
.lineLimit(1)
.minimumScaleFactor(0.8)
.font(.system(size: 13).monospacedDigit())
.foregroundStyle(.secondary)
.environment(\.locale, activityLocale(context))
}
}
HStack(spacing: 10) {
// The button has no text baseline of its own, so only the
// countdown and its caption share one.
HStack(alignment: .lastTextBaseline, spacing: 10) {
activityCountdownText(context, now: timeline.date, size: 44, dimmed: isLuminanceReduced, foreground: .primary)
if !activityIsFocus(context), let caption = activityCaption(context, at: timeline.date) {
Text(caption)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
}
Spacer(minLength: 8)
if !activityComplete(context, at: timeline.date) {
AddPomodoroButton(state: context.state, now: timeline.date, startAtMs: leg?.startAtMs ?? context.attributes.shiftStartAtMs)
if let label = context.state.stopFocusLabel,
leg?.surface == "focus", leg?.isPreview != true {
Link(destination: focusActionURL(.stop, startAtMs: leg?.startAtMs ?? context.attributes.shiftStartAtMs)) {
Image(systemName: "stop.fill")
.font(.system(size: 15, weight: .semibold))
.frame(width: 44, height: 44)
.background(.primary.opacity(0.12), in: .circle)
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.fixedSize()
.accessibilityLabel(label)
}
}
}
.padding(.top, 12)
if leg?.isPreview != true {
activityProgress(context, at: timeline.date)
.padding(.top, 14)
}
// Only one activity may be live, so a focus block ends the work
// countdown. These two lines put the shift back: the block above,
// the shift below — the canvas's first two scales in one card.
let support = activitySupportLine(context, at: timeline.date)
if support != nil || context.state.shiftEndAtMs != nil {
HStack(spacing: 8) {
if let support {
Text(support).lineLimit(1).minimumScaleFactor(0.85)
}
Spacer(minLength: 4)
if let endAtMs = context.state.shiftEndAtMs, let label = context.state.shiftEndLabel {
HStack(spacing: 4) {
Text(label)
Text(Date(timeIntervalSince1970: Double(endAtMs) / 1_000), style: .time)
.environment(\.locale, activityLocale(context))
}
.lineLimit(1)
}
}
.font(.system(size: 12))
.foregroundStyle(.secondary)
.padding(.top, 10)
}
}
.padding(.horizontal, 18)
.padding(.vertical, 16)
}
}
}
/// Opens the running task in the app to choose and confirm additional blocks.
///
/// The trailing edge of the hero row is where the system's own timer puts its
/// controls, so this is the ordinary place for it rather than an invention.
/// It disappears entirely on a break or a work countdown, and greys out when
/// the rest of the shift already belongs to other tasks — a control that can
/// be tapped and does nothing is worse than one that says it cannot act.
private struct AddPomodoroButton: View {
let state: OffWorkActivityAttributes.ContentState
let now: Date
let startAtMs: Int64
var body: some View {
if state.showsAddPomodoro(atMs: Int64(now.timeIntervalSince1970 * 1_000)),
let label = state.addPomodoroLabel {
Link(destination: focusActionURL(.addPomodoros, startAtMs: startAtMs)) {
Image(systemName: "plus")
.font(.system(size: 15, weight: .semibold))
.frame(width: 44, height: 44)
.background(.primary.opacity(0.12), in: .circle)
.contentShape(.rect)
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.disabled(!state.addPomodoroEnabled)
.accessibilityLabel(label)
// The timer text beside it reports an ideal width far wider than
// the digits it draws, and would otherwise squeeze the button out
// of the row entirely.
.fixedSize()
.layoutPriority(1)
}
}
}
// MARK: - Countdown surfaces
private struct ActivityCountdownPanel: View {
let context: ActivityViewContext<OffWorkActivityAttributes>
let now: Date
let size: CGFloat
var body: some View {
VStack(spacing: activityIsFocus(context) ? 6 : 14) {
HStack {
if activityIsFocus(context), !activityComplete(context, at: now),
activityLeg(context, at: now)?.isPreview != true {
// The interval timer fills the available width without
// the .timer style's oversized ideal width shrinking it.
let end = activityEnd(context, at: now)
Text(timerInterval: now...max(now, end), countsDown: true)
.font(.system(size: size, weight: .bold).monospacedDigit())
.foregroundStyle(.white)
.environment(\.locale, activityLocale(context))
.lineLimit(1)
.minimumScaleFactor(0.8)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
activityCountdownText(context, now: now, size: size)
Spacer()
}
if !activityIsFocus(context) {
Text(String(format: "%.1f%%", activityProgressValue(context, at: now)))
.font(.system(size: 15, weight: .semibold).monospacedDigit())
.foregroundStyle(activityOrange)
}
}
if activityLeg(context, at: now)?.isPreview != true {
activityProgress(context, at: now)
}
}
}
}
private struct ActivityCompactGlyph: View {
let context: ActivityViewContext<OffWorkActivityAttributes>
let size: CGFloat
let brandSize: CGFloat
var body: some View {
TimelineView(activitySchedule(context)) { timeline in
if let symbol = activitySymbol(context, at: timeline.date) {
Image(systemName: symbol)
.font(.system(size: size, weight: .semibold))
.foregroundStyle(activityTint(context, at: timeline.date))
} else {
AlwaysDarkBrandMark(size: brandSize)
}
}
}
}
/// The minimal presentation: a progress ring with the activity's glyph inside.
///
/// The circle is too small for digits, so progress is the only thing it can
/// add — and a ring is what iOS itself puts there. The work countdown wears no
/// glyph inside it: the brand mark is a ring already, and two concentric ones
/// say the same thing twice. Focus and break keep their symbol, which is the
/// only thing that tells those two apart from the countdown.
private struct ActivityMinimalRing: View {
let context: ActivityViewContext<OffWorkActivityAttributes>
var body: some View {
TimelineView(activitySchedule(context)) { timeline in
let tint = activityTint(context, at: timeline.date)
ZStack {
// Our own track, drawn under the system's ring. It closes the
// wedge gaps the mask leaves, and it is a plain shape: if the
// minimal slot ever refuses to draw the progress ring, the
// circle still reads as ours rather than as nothing.
Circle().strokeBorder(tint.opacity(0.28), lineWidth: 2.5)
ring(at: timeline.date)
// The brand mark is itself a ring, so drawing it inside this
// one gives two concentric circles and says nothing twice.
// The work countdown is the ring; focus and break keep their
// symbol, which is a shape the ring does not repeat.
if let symbol = activitySymbol(context, at: timeline.date) {
Image(systemName: symbol)
.font(.system(size: 8, weight: .semibold))
.foregroundStyle(tint)
}
}
.frame(width: 20, height: 20)
}
}
@ViewBuilder
private func ring(at date: Date) -> some View {
let tint = activityTint(context, at: date)
if activityComplete(context, at: date) {
Circle().strokeBorder(tint, lineWidth: 2.5)
} else if activityIsFocus(context), let span = activitySpan(context, at: date) {
ActivitySegmentedRing(
segments: [.init(startAtMs: span.start, endAtMs: span.end)],
tint: tint
)
} else if !context.state.windowSegments.isEmpty {
ActivitySegmentedRing(segments: context.state.windowSegments, tint: tint)
}
}
}
/// One ring per effective segment, each masked to its own wedge of the circle.
///
/// Same reasoning as the lock-screen bar: only `ProgressView(timerInterval:)`
/// keeps moving while the extension is suspended, and a single ring stretched
/// across the shift would count the lunch gap as worked time. Each ring is
/// given the whole effective duration as its interval and started early by the
/// effective time before it, so its sweep crosses its own wedge exactly while
/// that segment is being worked, and is masked away everywhere else. Over the
/// gap nothing advances, which is the arithmetic `projectedProgress` does.
private struct ActivitySegmentedRing: View {
let segments: [OffWorkActivityAttributes.ContentState.Segment]
let tint: Color
private struct Arc: Identifiable {
let id: Int64
let interval: ClosedRange<Date>
let from: Double
let to: Double
}
private var arcs: [Arc] {
let durations = segments.map { max(0, $0.endAtMs - $0.startAtMs) }
let total = durations.reduce(Int64(0), +)
guard total > 0 else { return [] }
var prior = Int64(0)
return zip(segments, durations).map { segment, duration in
let start = Date(timeIntervalSince1970: Double(segment.startAtMs - prior) / 1_000)
let from = Double(prior) / Double(total)
prior += duration
return Arc(
id: segment.startAtMs,
interval: start...start.addingTimeInterval(Double(total) / 1_000),
from: from,
to: Double(prior) / Double(total)
)
}
}
var body: some View {
let arcs = arcs
ZStack {
ForEach(arcs) { arc in
ring(arc, masked: arcs.count > 1)
}
}
}
/// A shift with no lunch owns the whole circle, and takes the plain ring
/// the system draws for every other timer — the wedge mask is only reached
/// when there is a gap that has to be held back.
@ViewBuilder
private func ring(_ arc: Arc, masked: Bool) -> some View {
let ring = ProgressView(timerInterval: arc.interval, countsDown: false) {
EmptyView()
} currentValueLabel: { EmptyView() }
.progressViewStyle(.circular)
.tint(tint)
.labelsHidden()
if masked {
ring.mask(ActivityRingWedge(from: arc.from, to: arc.to))
} else {
ring
}
}
}
/// The slice of the ring one segment owns. A single segment owns all of it,
/// which is returned as the plain rect so no arc has to close a full turn.
private struct ActivityRingWedge: Shape {
let from: Double
let to: Double
func path(in rect: CGRect) -> Path {
guard to - from < 1 else { return Path(rect) }
let center = CGPoint(x: rect.midX, y: rect.midY)
var path = Path()
path.move(to: center)
// The ring starts at twelve o'clock; `Path` angles start at three.
// The radius overshoots so a thick stroke is never clipped short.
path.addArc(
center: center,
radius: rect.width + rect.height,
startAngle: .degrees(from * 360 - 90),
endAngle: .degrees(to * 360 - 90),
clockwise: false
)
path.closeSubpath()
return path
}
}
private struct ActivityCompactCountdown: View {
let context: ActivityViewContext<OffWorkActivityAttributes>
var body: some View {
TimelineView(activitySchedule(context)) { timeline in
if activityComplete(context, at: timeline.date) {
// completedCaption is a full, localized sentence (6 characters
// in zh-CN, 36 in fr) meant for the lock screen and expanded
// panel where there's room for it. The compact slot has none:
// `.fixedSize` reports the sentence's true, un-scaled width as
// "ideal," the region doesn't fall back to minimumScaleFactor
// the way normal layout would, and the trailing-anchored text
// just clips off its own leading character instead. A
// checkmark needs no localization and can't overflow.
Image(systemName: "checkmark")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white)
} else if let leg = activityLeg(context, at: timeline.date), leg.isPreview {
// Nothing is running, so nothing may tick. The hour it starts
// is both shorter and the only true thing to show.
Text(Date(timeIntervalSince1970: Double(leg.startAtMs) / 1_000), style: .time)
.font(.system(size: 13, weight: .semibold).monospacedDigit())
.foregroundStyle(.white)
.environment(\.locale, activityLocale(context))
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
} else {
// The render server interpolates this bounded timer while the
// extension sleeps. It stops at zero even if the next leg has
// not received a redraw; a date-style timer would count up again.
//
// The explicit width below is still required. Timer text in
// Dynamic Island's compact regions reports a broken, oversized
// ideal width (https://developer.apple.com/forums/thread/723316),
// and unbounded it blows the island out to its expanded shape
// with the countdown rendered blank.
Text(timerInterval: timeline.date...max(timeline.date, activityEnd(context, at: timeline.date)), countsDown: true)
.font(.system(size: 14, weight: .bold).monospacedDigit())
.foregroundStyle(.white)
.environment(\.locale, activityLocale(context))
.lineLimit(1)
.minimumScaleFactor(0.6)
// Align the timer's glyphs inside its reserved width, not
// just the outer frame. Seconds stay against the capsule
// edge even when the timer loses a minute or hour digit.
.multilineTextAlignment(.trailing)
.frame(width: compactTimerWidth(context, now: timeline.date), alignment: .trailing)
}
}
}
}
/// Width for the compact island's `.timer` text, sized to the digits the
/// system is actually showing right now.
///
/// A single fixed width cannot work: `Text(_:style:.timer)` must be given a
/// bounded width (unbounded, it reports a broken ideal width and blows the
/// island out), but the format it renders shrinks from `H:MM:SS` to `MM:SS`
/// to `M:SS` as the shift runs down, and any box wide enough for the longest
/// one leaves dead space inside the shorter ones. `minimumScaleFactor` on the
/// text absorbs the rounding, so these only have to be close.
private func compactTimerWidth(
_ context: ActivityViewContext<OffWorkActivityAttributes>,
now: Date
) -> CGFloat {
let endMs = activityLeg(context, at: now)?.endAtMs ?? context.state.endAtMs
let remaining = Double(endMs) / 1_000 - now.timeIntervalSince1970
// ~9pt per glyph at 14pt bold monospaced digits.
if remaining >= 36_000 { return 74 } // HH:MM:SS
if remaining >= 3_600 { return 65 } // H:MM:SS
if remaining >= 600 { return 47 } // MM:SS
return 38 // M:SS
}
private struct ActivityAppIcon: View {
var size: CGFloat
var body: some View {
Image("BrandIcon")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.accessibilityHidden(true)
}
}
/// Dynamic Island and the lock-screen Live Activity are always-dark
/// surfaces. `BrandMark` only swaps the hands by appearance; the light
/// catalog's plum hands disappear on the black capsule, so pin the dark
/// variant here instead of inheriting the phone's color scheme.
private struct AlwaysDarkBrandMark: View {
var size: CGFloat
var body: some View {
Image(uiImage: Self.image)
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.accessibilityHidden(true)
}
private static let image: UIImage = {
let traits = UITraitCollection(userInterfaceStyle: .dark)
let named = UIImage(named: "BrandMark")
return named?.imageAsset?.image(with: traits) ?? named ?? UIImage()
}()
}
// MARK: - Update scheduling
/// When the card asks the system for a new frame.
///
/// Almost everything inside a leg draws itself: `Text(_:style:.timer)` and
/// `ProgressView(timerInterval:)` are animated by the render server without
/// waking this extension at all. What cannot draw itself is the hand-over —
/// the instant a block becomes its break, and the break becomes the block
/// queued behind it — because that swaps the whole card.
///
/// These boundaries are best-effort redraw requests, not scheduled ActivityKit
/// updates. iOS can suspend the extension across all of them. Bounded timer
/// text remains safe then; notifications deliver the transitions independently.
private struct ActivityUpdateSchedule: TimelineSchedule {
var boundaries: [Date]
var interval: TimeInterval?
func entries(from startDate: Date, mode: TimelineScheduleMode) -> AnyIterator<Date> {
var pending = boundaries.filter { $0 > startDate }.sorted()
var cursor = startDate
var emittedStart = false
// Always-On Display asks for about a frame a minute. The boundaries
// still land exactly; only the filler ticks between them coarsen.
let interval = interval.map { mode == .lowFrequency ? max($0, 60) : $0 }
return AnyIterator {
if !emittedStart {
emittedStart = true
return startDate
}
guard let interval else {
return pending.isEmpty ? nil : pending.removeFirst()
}
let tick = cursor.addingTimeInterval(interval)
if let next = pending.first, next <= tick {
pending.removeFirst()
cursor = next
return next
}
cursor = tick
return tick
}
}
}
private func activitySchedule(
_ context: ActivityViewContext<OffWorkActivityAttributes>
) -> ActivityUpdateSchedule {
let legs = context.state.legs ?? []
let boundaries = legs.isEmpty
? [Date(timeIntervalSince1970: Double(context.state.endAtMs) / 1_000)]
: legs.map { Date(timeIntervalSince1970: Double($0.endAtMs) / 1_000) }
return .init(boundaries: boundaries, interval: legs.isEmpty ? 1 : 60)
}
// MARK: - Chain readers
private let activityOrange = Color(red: 0.976, green: 0.451, blue: 0.086)
/// The leg the clock is in. `nil` past the end of the chain, and on payloads
/// written before the chain existed — both fall back to the single countdown
/// those carry.
private func activityLeg(
_ context: ActivityViewContext<OffWorkActivityAttributes>,
at date: Date
) -> OffWorkActivityAttributes.ContentState.Leg? {
context.state.leg(atMs: Int64(date.timeIntervalSince1970 * 1_000))
}
/// The instant this panel is counting to right now, which moves from the
/// running block to its break to the block queued behind them.
private func activityEnd(
_ context: ActivityViewContext<OffWorkActivityAttributes>,
at date: Date
) -> Date {
let endMs = activityLeg(context, at: date)?.endAtMs ?? context.state.endAtMs
return Date(timeIntervalSince1970: Double(endMs) / 1_000)
}
private func activityComplete(_ context: ActivityViewContext<OffWorkActivityAttributes>, at date: Date) -> Bool {
context.state.phase == "complete"
|| date.timeIntervalSince1970 * 1_000 >= Double(context.state.chainEndAtMs)
}
private func activityDestination(_ context: ActivityViewContext<OffWorkActivityAttributes>) -> URL? {
context.state.destination.flatMap(URL.init(string:)) ?? URL(string: "offworkcountdown://timer")
}
/// Whether this is a focus or break activity at all, as opposed to the work
/// countdown. Structural, so it deliberately does not depend on the clock.
private func activityIsFocus(_ context: ActivityViewContext<OffWorkActivityAttributes>) -> Bool {
guard let surface = context.state.surface else { return false }
return surface != "work"
}
private func activityTitle(
_ context: ActivityViewContext<OffWorkActivityAttributes>,
at date: Date
) -> String {
// The task wins over the phase: "Spec review" says more than "Focus", and
// the countdown beside it already establishes that this is a timer.
if let leg = activityLeg(context, at: date) { return leg.title ?? leg.label }
return context.state.taskTitle ?? context.state.timerLabel ?? context.state.appTitle
}
private func activityTaskTitle(
_ context: ActivityViewContext<OffWorkActivityAttributes>,
at date: Date
) -> String? {
activityLeg(context, at: date)?.title ?? context.state.taskTitle
}
/// The phase, not the task: "Focus", "Short break", "Next block".
private func activityPhaseLabel(
_ context: ActivityViewContext<OffWorkActivityAttributes>,
at date: Date
) -> String {
activityLeg(context, at: date)?.label
?? context.state.timerLabel
?? context.state.appTitle
}
private func activityCaption(
_ context: ActivityViewContext<OffWorkActivityAttributes>,
at date: Date
) -> String? {
if activityComplete(context, at: date) {
// A finished chain already prints its closing line in the hero. The
// work countdown keeps its separate note, which says something else.
return context.state.chainDoneCaption == nil ? context.state.completedNote : nil
}
return activityLeg(context, at: date)?.label ?? context.state.caption
}
/// The support line under the bar: what this block is worth, or what follows
/// it. Never both — the card has one line for it and the countdown owns the
/// attention above.
private func activitySupportLine(
_ context: ActivityViewContext<OffWorkActivityAttributes>,
at date: Date
) -> String? {
guard !activityComplete(context, at: date) else { return nil }
guard let leg = activityLeg(context, at: date) else { return context.state.nextLabel }
if let detail = leg.detail { return detail }
return leg.nextNote
}
/// Focus indigo, break teal, work orange — the canvas palette, where orange
/// means "now" and "selected" rather than a kind of time.
private let activityFocusTint = Color(red: 0.368, green: 0.360, blue: 0.902)
private let activityBreakTint = Color(red: 0.251, green: 0.784, blue: 0.878)
private func activityTint(
_ context: ActivityViewContext<OffWorkActivityAttributes>,
at date: Date
) -> Color {
switch activityLeg(context, at: date)?.surface ?? context.state.surface {
case "focus": activityFocusTint
case "shortBreak", "longBreak": activityBreakTint
default: activityOrange
}
}
/// nil for the work countdown, which keeps the brand mark.
private func activitySymbol(
_ context: ActivityViewContext<OffWorkActivityAttributes>,
at date: Date
) -> String? {
guard activityIsFocus(context) else { return nil }
return activityLeg(context, at: date)?.icon ?? context.state.taskIcon ?? "stopwatch"
}
@ViewBuilder
private func activityCountdownText(
_ context: ActivityViewContext<OffWorkActivityAttributes>,
now: Date,
size: CGFloat,
dimmed: Bool = false,
foreground: Color = .white
) -> some View {
if activityComplete(context, at: now) {
Text(context.state.chainDoneCaption ?? context.state.completedCaption)
.font(.system(size: activityIsFocus(context) ? min(size, 22) : size, weight: activityIsFocus(context) ? .semibold : .bold))
.foregroundStyle(foreground)
.lineLimit(1)
.minimumScaleFactor(0.58)
} else if let leg = activityLeg(context, at: now), leg.isPreview {
// A block the plan holds and nobody has started. Its hour is the fact;
// a running countdown here would be a claim that it began.
Text(Date(timeIntervalSince1970: Double(leg.startAtMs) / 1_000), style: .time)
.font(.system(size: size, weight: .bold).monospacedDigit())
.foregroundStyle(foreground)
.environment(\.locale, activityLocale(context))
.lineLimit(1)
.minimumScaleFactor(0.58)
} else if dimmed {
// The dimmed screen coarsens a timer to whole minutes, but only for
// the date style: `Text(timerInterval:)` comes back as "8:--" with its
// seconds struck through, while `Text(_:style: .timer)` becomes
// "8 minutes" — which is what the rectangular complication has always
// shown there, and it is the better read at a glance.
//
// The render server owns this text too, so unlike the duration this
// branch used to compute from `now`, it does not freeze at whatever
// minute the screen dimmed on. What it gives up is the bounded end:
// a date timer counts back up from zero, so a shift that ends while
// the screen is dimmed and the extension is never woken shows a small
// rising number until something redraws the card.
Text(activityEnd(context, at: now), style: .timer)
.font(.system(size: min(size, 38), weight: .bold).monospacedDigit())
.foregroundStyle(foreground)
.environment(\.locale, activityLocale(context))
.lineLimit(1)
.minimumScaleFactor(0.58)
} else {
Text(timerInterval: now...max(now, activityEnd(context, at: now)), countsDown: true)
.font(.system(size: size, weight: .bold).monospacedDigit())
.foregroundStyle(foreground)
// Live Activities run in the Widget extension, whose process
// locale can differ from the language selected inside the app, and
// the dimmed branch above prints a localized duration.
.environment(\.locale, activityLocale(context))
.lineLimit(1)
.minimumScaleFactor(0.58)
}
}
private func activityLocale(_ context: ActivityViewContext<OffWorkActivityAttributes>) -> Locale {
Locale(identifier: context.state.locale)
}
private func activityProgressValue(_ context: ActivityViewContext<OffWorkActivityAttributes>, at date: Date) -> Double {
if activityComplete(context, at: date) { return 100 }
// Focus/break payloads advance from the current leg's start to its end,
// rather than across the shift's effective segments.
if activityIsFocus(context) {
let nowMs = Int64(date.timeIntervalSince1970 * 1_000)
guard let span = activitySpan(context, at: date) else { return context.state.progress }
let total = max(1, span.end - span.start)
return min(100, max(0, Double(nowMs - span.start) / Double(total) * 100))
}
return context.state.windowProgress(
atMs: Int64(date.timeIntervalSince1970 * 1_000)
)
}
private func activitySpan(
_ context: ActivityViewContext<OffWorkActivityAttributes>,
at date: Date
) -> (start: Int64, end: Int64)? {
if let leg = activityLeg(context, at: date) { return (leg.startAtMs, leg.endAtMs) }
guard let segment = context.state.segments.first else { return nil }
return (segment.startAtMs, segment.endAtMs)
}
@ViewBuilder
private func activityProgress(_ context: ActivityViewContext<OffWorkActivityAttributes>, at date: Date) -> some View {
if activityIsFocus(context), let span = activitySpan(context, at: date) {
let start = Date(timeIntervalSince1970: Double(span.start) / 1_000)
let end = Date(timeIntervalSince1970: Double(span.end) / 1_000)
// System interpolation keeps this moving without waking the extension.
ProgressView(timerInterval: start...max(start, end), countsDown: false) {
EmptyView()
} currentValueLabel: { EmptyView() }
.tint(activityTint(context, at: date))
.labelsHidden()
.frame(height: 6)
} else if !activityComplete(context, at: date), !context.state.windowSegments.isEmpty {
activitySegmentedProgress(context.state.windowSegments)
} else {
activityProgress(activityProgressValue(context, at: date))
}
}
/// The shift's effective segments, side by side, each filling over its own
/// interval so the render server can animate them with the extension asleep.
///
/// A width computed here from `now` needs a wake-up per frame, which is what
/// Always-On Display does not give — the bar froze while the countdown above
/// it kept moving. One `ProgressView(timerInterval:)` stretched across the
/// whole shift would draw itself, but it would also count the lunch gap as
/// worked time. Per segment, the gap simply has no bar to advance, which is
/// the same arithmetic `projectedProgress` does.
private func activitySegmentedProgress(
_ segments: [OffWorkActivityAttributes.ContentState.Segment]
) -> some View {
let durations = segments.map { max(0, $0.endAtMs - $0.startAtMs) }
let total = durations.reduce(Int64(0), +)
return GeometryReader { proxy in
HStack(spacing: 0) {
ForEach(Array(zip(segments, durations)), id: \.0.startAtMs) { segment, duration in
let start = Date(timeIntervalSince1970: Double(segment.startAtMs) / 1_000)
let end = Date(timeIntervalSince1970: Double(segment.endAtMs) / 1_000)
ProgressView(timerInterval: start...max(start, end), countsDown: false) {
EmptyView()
} currentValueLabel: { EmptyView() }
.tint(activityOrange)
.labelsHidden()
.frame(width: proxy.size.width * Double(duration) / Double(max(1, total)))
}
}
}
.frame(height: 6)
}
private func activityProgress(_ progress: Double) -> some View {
GeometryReader { proxy in
Capsule().fill(.primary.opacity(0.16))
.overlay(alignment: .leading) {
Capsule().fill(activityOrange)
.frame(width: proxy.size.width * min(1, max(0, progress / 100)))
}
}
.frame(height: 6)
}
private func focusActionURL(_ action: FocusActivityRequest.Action, startAtMs: Int64) -> URL {
// Only a fixed action and an integer enter this URL; no task title or salary.
URL(string: "offworkcountdown://focus?action=\(action.rawValue)&start=\(startAtMs)")!
}