Вынесена реализация виджета в отдельный target с корректным embedding и Info.plist, чтобы сборка и запуск работали стабильно на устройстве, а также улучшена адаптивная верстка меток и фонового контейнера для соответствия требованиям WidgetKit. Co-authored-by: Cursor <cursoragent@cursor.com>
196 lines
6.6 KiB
Swift
196 lines
6.6 KiB
Swift
import SwiftUI
|
||
import WidgetKit
|
||
|
||
struct ClockEntry: TimelineEntry {
|
||
let date: Date
|
||
let hourAngle: Double
|
||
let minuteAngle: Double
|
||
let secondAngle: Double
|
||
}
|
||
|
||
struct ClockProvider: TimelineProvider {
|
||
func placeholder(in context: Context) -> ClockEntry {
|
||
ClockEntry(date: Date(), hourAngle: 0, minuteAngle: 0, secondAngle: 0)
|
||
}
|
||
|
||
func getSnapshot(in context: Context, completion: @escaping (ClockEntry) -> Void) {
|
||
completion(makeEntry(for: Date()))
|
||
}
|
||
|
||
func getTimeline(in context: Context, completion: @escaping (Timeline<ClockEntry>) -> Void) {
|
||
let now = Date()
|
||
let entry = makeEntry(for: now)
|
||
|
||
// Widgets are budget-limited; minute updates are realistic for production behavior.
|
||
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 1, to: now) ?? now.addingTimeInterval(60)
|
||
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
|
||
}
|
||
|
||
private func makeEntry(for date: Date) -> ClockEntry {
|
||
let hourAngle = calculateHourAngle(date: date)
|
||
let minuteAngle = (hourAngle * 360.0).truncatingRemainder(dividingBy: 360.0)
|
||
let secondAngle = (minuteAngle * 60.0).truncatingRemainder(dividingBy: 360.0)
|
||
return ClockEntry(date: date, hourAngle: hourAngle, minuteAngle: minuteAngle, secondAngle: secondAngle)
|
||
}
|
||
|
||
private func calculateHourAngle(date: Date) -> Double {
|
||
let calendar = Calendar.current
|
||
let hour = calendar.component(.hour, from: date)
|
||
let minute = calendar.component(.minute, from: date)
|
||
let second = calendar.component(.second, from: date)
|
||
let baseHourAngle = Double(hour) * 15.0
|
||
let minuteOffset = Double(minute) * 0.25
|
||
let secondOffset = Double(second) * 0.0042
|
||
return baseHourAngle + minuteOffset + secondOffset
|
||
}
|
||
}
|
||
|
||
struct ClockWidgetView: View {
|
||
@Environment(\.widgetFamily) private var family
|
||
let entry: ClockEntry
|
||
|
||
private var labelStep: Int {
|
||
switch family {
|
||
case .systemSmall:
|
||
return 3 // 0,45,90... для компактного размера
|
||
case .systemMedium:
|
||
return 2 // 0,30,60... для лучшей читаемости
|
||
default:
|
||
return 1
|
||
}
|
||
}
|
||
|
||
private var labelFont: Font {
|
||
switch family {
|
||
case .systemSmall:
|
||
return .system(size: 7, weight: .bold, design: .rounded)
|
||
case .systemMedium:
|
||
return .system(size: 8, weight: .bold, design: .rounded)
|
||
default:
|
||
return .caption2
|
||
}
|
||
}
|
||
|
||
private var titleFont: Font {
|
||
family == .systemSmall ? .system(size: 10, weight: .bold) : .caption
|
||
}
|
||
|
||
private var titleBottomPadding: CGFloat {
|
||
family == .systemSmall ? 2 : 6
|
||
}
|
||
|
||
private var dialScale: CGFloat {
|
||
family == .systemSmall ? 0.88 : 0.84
|
||
}
|
||
|
||
private var labelRadiusRatio: CGFloat {
|
||
family == .systemSmall ? 0.31 : 0.34
|
||
}
|
||
|
||
private var handConfig: (second: (CGFloat, CGFloat), minute: (CGFloat, CGFloat), hour: (CGFloat, CGFloat), center: CGFloat) {
|
||
if family == .systemSmall {
|
||
return ((0.30, 1.0), (0.22, 1.4), (0.17, 2.4), 3.5)
|
||
}
|
||
return ((0.35, 1.0), (0.25, 1.5), (0.2, 3.0), 4.0)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
Text("360 Clock")
|
||
.font(titleFont)
|
||
.padding(.bottom, titleBottomPadding)
|
||
|
||
GeometryReader { geometry in
|
||
let size = min(geometry.size.width, geometry.size.height)
|
||
let dialSize = size * dialScale
|
||
|
||
ZStack {
|
||
Circle()
|
||
.stroke(Color.primary, lineWidth: 1)
|
||
.frame(width: dialSize, height: dialSize)
|
||
|
||
ForEach(0..<24, id: \.self) { index in
|
||
if index % labelStep == 0 {
|
||
let angle = Double(index * 15) - 90
|
||
let radius = dialSize * labelRadiusRatio
|
||
let x = cos(angle * .pi / 180) * radius
|
||
let y = sin(angle * .pi / 180) * radius
|
||
|
||
Text("\(index * 15)")
|
||
.font(labelFont)
|
||
.monospacedDigit()
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.6)
|
||
.offset(x: x, y: y)
|
||
}
|
||
}
|
||
|
||
Circle()
|
||
.fill(.red)
|
||
.frame(width: handConfig.center, height: handConfig.center)
|
||
|
||
ClockHand(angle: entry.secondAngle, length: dialSize * handConfig.second.0, width: handConfig.second.1, color: .primary)
|
||
ClockHand(angle: entry.minuteAngle, length: dialSize * handConfig.minute.0, width: handConfig.minute.1, color: .blue)
|
||
ClockHand(angle: entry.hourAngle, length: dialSize * handConfig.hour.0, width: handConfig.hour.1, color: .red)
|
||
}
|
||
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .center)
|
||
}
|
||
}
|
||
.padding(8)
|
||
.widgetBackgroundCompat()
|
||
}
|
||
}
|
||
|
||
private extension View {
|
||
@ViewBuilder
|
||
func widgetBackgroundCompat() -> some View {
|
||
if #available(iOS 17.0, *) {
|
||
self.containerBackground(.fill.tertiary, for: .widget)
|
||
} else {
|
||
self
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ClockHand: View {
|
||
let angle: Double
|
||
let length: CGFloat
|
||
let width: CGFloat
|
||
let color: Color
|
||
|
||
var body: some View {
|
||
Rectangle()
|
||
.fill(color)
|
||
.frame(width: width, height: length)
|
||
.offset(y: -length / 2)
|
||
.rotationEffect(.degrees(angle))
|
||
}
|
||
}
|
||
|
||
struct ClockWidget: Widget {
|
||
let kind = "ClockWidget"
|
||
|
||
var body: some WidgetConfiguration {
|
||
StaticConfiguration(kind: kind, provider: ClockProvider()) { entry in
|
||
ClockWidgetView(entry: entry)
|
||
}
|
||
.configurationDisplayName("360 Clock")
|
||
.description("Показывает время с 24-часовым 360° циферблатом.")
|
||
.supportedFamilies([.systemSmall, .systemMedium])
|
||
}
|
||
}
|
||
|
||
@main
|
||
struct ClockWidgetBundle: WidgetBundle {
|
||
var body: some Widget {
|
||
ClockWidget()
|
||
}
|
||
}
|
||
|
||
struct ClockWidget_Previews: PreviewProvider {
|
||
static var previews: some View {
|
||
ClockWidgetView(entry: ClockEntry(date: Date(), hourAngle: 90, minuteAngle: 180, secondAngle: 270))
|
||
.previewContext(WidgetPreviewContext(family: .systemSmall))
|
||
}
|
||
}
|