From 0697440437f6aa93305aefdecf9f4626ef935857 Mon Sep 17 00:00:00 2001 From: bilal Date: Wed, 17 Jun 2026 18:59:07 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D1=86=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20Widget=20Extension=20=D0=B8=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BE=D1=82=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=86=D0=B8=D1=84?= =?UTF-8?q?=D0=B5=D1=80=D0=B1=D0=BB=D0=B0=D1=82=D0=B0=20=D0=B2=20=D0=B2?= =?UTF-8?q?=D0=B8=D0=B4=D0=B6=D0=B5=D1=82=D0=B5.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вынесена реализация виджета в отдельный target с корректным embedding и Info.plist, чтобы сборка и запуск работали стабильно на устройстве, а также улучшена адаптивная верстка меток и фонового контейнера для соответствия требованиям WidgetKit. Co-authored-by: Cursor --- 360Clock.xcodeproj/project.pbxproj | 188 ++++++++++++++++- .../xcschemes/xcschememanagement.plist | 14 -- 360Clock/ClockApp.swift | 10 +- 360Clock/ClockView.swift | 8 +- 360Clock/Info.plist | 50 +++++ ClockWidgetExtension/ClockWidgetBundle.swift | 195 ++++++++++++++++++ ClockWidgetExtension/Info.plist | 29 +++ README.md | 33 +++ 8 files changed, 501 insertions(+), 26 deletions(-) delete mode 100755 360Clock.xcodeproj/xcuserdata/bilal.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 360Clock/Info.plist create mode 100644 ClockWidgetExtension/ClockWidgetBundle.swift create mode 100644 ClockWidgetExtension/Info.plist create mode 100644 README.md diff --git a/360Clock.xcodeproj/project.pbxproj b/360Clock.xcodeproj/project.pbxproj index fe5aac4..7e6c316 100755 --- a/360Clock.xcodeproj/project.pbxproj +++ b/360Clock.xcodeproj/project.pbxproj @@ -6,16 +6,73 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + A1B2C3082FD0000000AAA001 /* ClockWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A1B2C3012FD0000000AAA001 /* ClockWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + A1B2C30A2FD0000000AAA001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 00A63A582D9073F2002E3CA7 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A1B2C3092FD0000000AAA001; + remoteInfo = ClockWidgetExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + A1B2C3072FD0000000AAA001 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + A1B2C3082FD0000000AAA001 /* ClockWidgetExtension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 00A63A602D9073F2002E3CA7 /* 360Clock.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = 360Clock.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A1B2C3012FD0000000AAA001 /* ClockWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ClockWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 9C75D4732FCCF11600A6065A /* Exceptions for "360Clock" folder in "360Clock" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 00A63A5F2D9073F2002E3CA7 /* 360Clock */; + }; + A1B2C3032FD0000000AAA001 /* Exceptions for "ClockWidgetExtension" folder in "ClockWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = A1B2C3092FD0000000AAA001 /* ClockWidgetExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 00A63A622D9073F2002E3CA7 /* 360Clock */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 9C75D4732FCCF11600A6065A /* Exceptions for "360Clock" folder in "360Clock" target */, + ); path = 360Clock; sourceTree = ""; }; + A1B2C3022FD0000000AAA001 /* ClockWidgetExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A1B2C3032FD0000000AAA001 /* Exceptions for "ClockWidgetExtension" folder in "ClockWidgetExtension" target */, + ); + path = ClockWidgetExtension; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -26,6 +83,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A1B2C3052FD0000000AAA001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -33,6 +97,7 @@ isa = PBXGroup; children = ( 00A63A622D9073F2002E3CA7 /* 360Clock */, + A1B2C3022FD0000000AAA001 /* ClockWidgetExtension */, 00A63A612D9073F2002E3CA7 /* Products */, ); sourceTree = ""; @@ -41,6 +106,7 @@ isa = PBXGroup; children = ( 00A63A602D9073F2002E3CA7 /* 360Clock.app */, + A1B2C3012FD0000000AAA001 /* ClockWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -55,10 +121,12 @@ 00A63A5C2D9073F2002E3CA7 /* Sources */, 00A63A5D2D9073F2002E3CA7 /* Frameworks */, 00A63A5E2D9073F2002E3CA7 /* Resources */, + A1B2C3072FD0000000AAA001 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( + A1B2C30B2FD0000000AAA001 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 00A63A622D9073F2002E3CA7 /* 360Clock */, @@ -70,6 +138,28 @@ productReference = 00A63A602D9073F2002E3CA7 /* 360Clock.app */; productType = "com.apple.product-type.application"; }; + A1B2C3092FD0000000AAA001 /* ClockWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = A1B2C30C2FD0000000AAA001 /* Build configuration list for PBXNativeTarget "ClockWidgetExtension" */; + buildPhases = ( + A1B2C3042FD0000000AAA001 /* Sources */, + A1B2C3052FD0000000AAA001 /* Frameworks */, + A1B2C3062FD0000000AAA001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A1B2C3022FD0000000AAA001 /* ClockWidgetExtension */, + ); + name = ClockWidgetExtension; + packageProductDependencies = ( + ); + productName = ClockWidgetExtension; + productReference = A1B2C3012FD0000000AAA001 /* ClockWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -78,11 +168,14 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1620; - LastUpgradeCheck = 1620; + LastUpgradeCheck = 2620; TargetAttributes = { 00A63A5F2D9073F2002E3CA7 = { CreatedOnToolsVersion = 16.2; }; + A1B2C3092FD0000000AAA001 = { + CreatedOnToolsVersion = 16.2; + }; }; }; buildConfigurationList = 00A63A5B2D9073F2002E3CA7 /* Build configuration list for PBXProject "360Clock" */; @@ -100,6 +193,7 @@ projectRoot = ""; targets = ( 00A63A5F2D9073F2002E3CA7 /* 360Clock */, + A1B2C3092FD0000000AAA001 /* ClockWidgetExtension */, ); }; /* End PBXProject section */ @@ -112,6 +206,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A1B2C3062FD0000000AAA001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -122,8 +223,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A1B2C3042FD0000000AAA001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + A1B2C30B2FD0000000AAA001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A1B2C3092FD0000000AAA001 /* ClockWidgetExtension */; + targetProxy = A1B2C30A2FD0000000AAA001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 00A63A6C2D9073F4002E3CA7 /* Debug */ = { isa = XCBuildConfiguration; @@ -160,6 +276,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = LFMN6WUGZ7; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -183,6 +300,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -223,6 +341,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = LFMN6WUGZ7; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -239,6 +358,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; @@ -252,12 +372,9 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"360Clock/Preview Content\""; - DEVELOPMENT_TEAM = LFMN6WUGZ7; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_FILE = 360Clock/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 16.6; @@ -282,10 +399,8 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"360Clock/Preview Content\""; - DEVELOPMENT_TEAM = LFMN6WUGZ7; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_FILE = 360Clock/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -304,6 +419,54 @@ }; name = Release; }; + A1B2C30D2FD0000000AAA001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = LFMN6WUGZ7; + INFOPLIST_FILE = ClockWidgetExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "bilal.-60Clock.ClockWidgetExtension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A1B2C30E2FD0000000AAA001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = LFMN6WUGZ7; + INFOPLIST_FILE = ClockWidgetExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "bilal.-60Clock.ClockWidgetExtension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -325,6 +488,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A1B2C30C2FD0000000AAA001 /* Build configuration list for PBXNativeTarget "ClockWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1B2C30D2FD0000000AAA001 /* Debug */, + A1B2C30E2FD0000000AAA001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 00A63A582D9073F2002E3CA7 /* Project object */; diff --git a/360Clock.xcodeproj/xcuserdata/bilal.xcuserdatad/xcschemes/xcschememanagement.plist b/360Clock.xcodeproj/xcuserdata/bilal.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100755 index 049be65..0000000 --- a/360Clock.xcodeproj/xcuserdata/bilal.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - SchemeUserState - - 360Clock.xcscheme_^#shared#^_ - - orderHint - 0 - - - - diff --git a/360Clock/ClockApp.swift b/360Clock/ClockApp.swift index 90e6941..fbdd061 100755 --- a/360Clock/ClockApp.swift +++ b/360Clock/ClockApp.swift @@ -1,10 +1,18 @@ import SwiftUI +import WidgetKit @main struct ClockApp: App { + @Environment(\.scenePhase) private var scenePhase + var body: some Scene { WindowGroup { ClockView() } + .onChange(of: scenePhase) { newPhase in + if newPhase == .active { + WidgetCenter.shared.reloadTimelines(ofKind: "ClockWidget") + } + } } -} \ No newline at end of file +} diff --git a/360Clock/ClockView.swift b/360Clock/ClockView.swift index aa441a0..4b02daf 100755 --- a/360Clock/ClockView.swift +++ b/360Clock/ClockView.swift @@ -127,6 +127,8 @@ struct ClockHand: View { } } -#Preview { - ClockView() -} +struct ClockView_Previews: PreviewProvider { + static var previews: some View { + ClockView() + } +} diff --git a/360Clock/Info.plist b/360Clock/Info.plist new file mode 100644 index 0000000..f5e34de --- /dev/null +++ b/360Clock/Info.plist @@ -0,0 +1,50 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + \ No newline at end of file diff --git a/ClockWidgetExtension/ClockWidgetBundle.swift b/ClockWidgetExtension/ClockWidgetBundle.swift new file mode 100644 index 0000000..99594db --- /dev/null +++ b/ClockWidgetExtension/ClockWidgetBundle.swift @@ -0,0 +1,195 @@ +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) -> 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)) + } +} diff --git a/ClockWidgetExtension/Info.plist b/ClockWidgetExtension/Info.plist new file mode 100644 index 0000000..2407b2f --- /dev/null +++ b/ClockWidgetExtension/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + 360 Clock Widget + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..827bbae --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# 360Clock + +iOS-приложение на SwiftUI с 24-часовым циферблатом (360°) и Home Screen виджетом на WidgetKit. + +## Что умеет + +- Основной экран с 24-часовой логикой углов. +- Виджет `systemSmall` и `systemMedium`. +- Три стрелки: часовая, минутная, секундная. +- Адаптивная разметка меток в виджете (без слипания на маленьком размере). + +## Структура проекта + +- `360Clock/ClockApp.swift` — точка входа приложения. +- `360Clock/ClockView.swift` — UI и расчеты для основного экрана. +- `360Clock/Info.plist` — `Info.plist` основного приложения. +- `ClockWidgetExtension/ClockWidgetBundle.swift` — виджет, timeline provider и UI виджета. +- `ClockWidgetExtension/Info.plist` — `Info.plist` extension-таргета. +- `360Clock.xcodeproj/project.pbxproj` — настройки таргетов app + widget extension. + +## Важно про обновление виджета + +WidgetKit не гарантирует секундные обновления на Home Screen. В текущей реализации timeline запрашивается с шагом в 1 минуту, что соответствует системным ограничениям и budget-политике iOS. + +## Сборка + +Открой `360Clock.xcodeproj` в Xcode, выбери схему `360Clock` и запускай на симуляторе или устройстве. + +Для проверки через CLI (без подписи): + +```bash +xcodebuild -scheme 360Clock -configuration Debug -destination 'generic/platform=iOS' -derivedDataPath './DerivedData' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build +``` \ No newline at end of file